CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# 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='<a superadmin access 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://<DOMAIN>`. 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 `<ip>.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-<date>-<time>/` (if that backup fails, nothing is removed), then removes FreeFrame's containers and network, the nightly backup timer, `/opt/freeframe` and the `freeframe` command. The data stays in its Docker volumes, so putting the saved `.env.prod` back and running the installer brings FreeFrame back with it (with `--yes` it needs no admin answers when the kept database has its admin). `--delete-data` deletes the volumes too, after you type `delete` (unattended: `--yes --delete-data --confirm-delete-data`); `--skip-backup` makes no final backup. Docker and its images, NVIDIA's toolkit, the apt sources, swap and firewall rules are left alone, with a note on how to remove each. `--dry-run` shows exactly what it would remove and keep, and changes nothing. It refuses a FreeFrame set up by hand. See "Removing FreeFrame" in `docs/deployment.md`. (#588)
### Changed
- **The web app's upload concurrency is a runtime setting, `UPLOAD_CONCURRENCY`.** It used to be baked into the web image at build time as `NEXT_PUBLIC_UPLOAD_CONCURRENCY`, so changing it meant rebuilding. The pages are now rendered per request and the server hands the value to the browser, so recreating the container is enough. `NEXT_PUBLIC_UPLOAD_CONCURRENCY` is still read when `UPLOAD_CONCURRENCY` is unset, and `docker-compose.prod.yml` no longer passes it as a build argument. The production web and API image builds also now ignore local files such as `node_modules` and `.next`, which makes a build from a development checkout much faster. (#584)
- **Promoting a release to `stable` also moves the `stable` image tag**, to the exact image the release's own tag points at (a retag, never a rebuild). Promotion now refuses, before the branch moves, a tag that has no published images. To roll back to a release from before images were published (older than v1.21.0), run it with `branch_only: true`, which moves only the branch; it is refused for a tag that has published images. (#585)
### Fixed
- **HTTPS certificates were never requested.** The production compose routes by path only, and Traefik takes the domain to request a certificate for from a host rule, so it logged `No domain parsed in provider ACME` and requested nothing, whatever `DOMAIN` and `ACME_EMAIL` were set to. The new opt-in `docker-compose.https.yml` adds host-based routers, so Traefik asks Let's Encrypt for `DOMAIN`, and redirects plain HTTP to HTTPS. The existing routers keep their rules. (#583)
- **Copy buttons work when FreeFrame is served over plain HTTP.** The share links table, a comment's Copy Link, the share link detail page and the collection share dialog called `navigator.clipboard` directly, which does not exist outside a secure context (for example `http://192.168.1.20:3000`), so the table and comment buttons threw and the others did nothing. They now use the shared clipboard helper, which falls back to the browser's copy command, and the copied state shows only when the copy worked. (#586)
- **A fresh production install now redirects to `/setup`.** The web app's setup check called the API at the browser's address, `/api`, which a server cannot resolve, so it failed quietly and the redirect to the setup page never happened in production. It now uses `API_INTERNAL_URL`, the address the branding fetch already used. (#584)
## [1.21.0] - 2026-10-24
Folder uploads and a project settings page. Drop a folder, or choose Upload folder, and its files upload with the folder tree recreated; a project's owner now edits its details, members and share page branding on one settings page, and every kind of share link shows the project's logo, colour and footer. The web app moves to Next.js 16 and React 19, leaving the Next.js 14 line, which no longer gets security fixes (see Security). This release also adds settings for the S3 storage class and the audio bitrate of review streams, transcodes portrait video at its own size, and carries fixes to uploads, compare, invites, sign-in and production logging.
### Upgrade notes
- **`middleware.ts` is now `proxy.ts`.** If you patched `apps/web/middleware.ts`, move the change into `apps/web/proxy.ts` and delete `middleware.ts`. Next 16 refuses to build with both files present. (#515 by @jeremy-pixelated)
- **The logo fields of `GET /instance/branding` are now paths relative to the API.** Each `*_url` field (for example `favicon_url`) used to be a full storage link; it is now a path such as `/instance/branding/logo/favicon?v=...`. If you call the API yourself, put your API URL in front of it before loading it. The web app already does. (#361)
- **`S3_STORAGE_CLASS` is unset by default, and unset behaves exactly as before.** No storage class is sent, so every object keeps landing in the bucket default. Setting it affects only objects written afterwards; existing media keeps its class. MinIO and Silo accept only `STANDARD` and `REDUCED_REDUNDANCY` and refuse any other value, which makes every upload fail to start, and Garage ignores the setting. (#514)
- **`next.config.js` no longer takes an `eslint` key.** If you run a custom `next.config.js`, remove it; Next 16 ignores the key and warns on every build. (#515 by @jeremy-pixelated)
- **Production containers log more by default.** The Celery workers and beat now run at `info` and the API writes an access log to stdout, so expect more log volume; at `info` the email worker also logs the recipient address of each email it sends. Set `CELERY_LOGLEVEL=warning` in `.env.prod` to keep the workers about as quiet as before; what the maintenance tasks deleted, and any that is switched off, still prints there as a warning. If you run your own compose file or override the API command, pass `--loglevel=info` to the Celery commands and `--access-logfile -` to gunicorn to get the same. (#262)
- **One migration.** `c5d1f3a7b9e2` adds `project_brandings.use_cover_as_logo` (boolean, default false), so no share page changes until an owner turns it on. It is a plain `ADD COLUMN` with a default and is quick on any size of table. The production compose runs `alembic upgrade head` when the API starts; if you start the API another way, run it first. (#576)
- **Pending invitees accept their invite before they can sign in.** A new invitee who signs in with an email code is told to open their invite link instead. An invitee who already signed in with a code on an earlier release keeps signing in that way, and their row offers Deactivate rather than Revoke Invite. (#472)
- **A sign-in code requested before the upgrade no longer works.** Codes are now stored per account, not per typed address, so a code emailed before the API restarted is refused; request a new one. Codes last 10 minutes, so this only affects sign-ins in progress during the upgrade. (#471)
- **`TRANSCODER_AUDIO_BITRATE` is new and empty by default, and empty encodes audio exactly as before.** Set it (for example `320k`, from `64k` to `512k`) only to raise the AAC bitrate of new transcodes; renditions already made keep their audio. (#328)
- **Portrait videos transcoded after the upgrade get larger renditions.** A 1080x1920 clip's top rendition is now 1080x1920 instead of 608x1080, so portrait encodes take longer and use more storage. A portrait source now gets only the rungs at or below its short side, so a 720x1280 clip no longer gets a 1080p rung. Existing renditions are not re-encoded. (#452)
- **The API, the Celery workers and beat now give up on a Redis broker call after 5 seconds.** There was no timeout before. A healthy Redis answers well within it; one that stalls longer now shows as a logged connection error and a retry instead of a hang. (#274)
- **Building the web app outside Docker needs Node 20.19 or newer (or 22.12+, 24+).** Next 16 refuses older Node. The Docker images (`node:20-alpine`) and CI already qualify, and `.nvmrc` now pins 20. (#515, #561)
- **If you script the API: share branding and project updates changed shape.** `GET /share/{token}` now always carries `branding` (`logo_url`, `primary_color`, `custom_footer`); its `logo_url` is a loadable link instead of a storage key, and `custom_title` is gone. `PUT /projects/{id}/branding` now clears a field sent as null instead of ignoring it, so send only the fields you mean to change, and `PATCH /projects/{id}` clears the description on null. (#576)
- **Share pages show project branding on every kind of link.** A project that has a logo, primary colour or footer set now shows them on folder, whole-project and multi-item links too, and its logo now appears ahead of the instance logo. (#576, #577)
- **The Upload button is a menu (Upload files, Upload folder), and the Upload asset dialog is gone.** Renaming a file before it uploads moves to its row in the Uploads panel. Update any team docs or screenshots that show the dialog. (#575)
### Added
- **Share pages get the project's branding for every kind of link.** `GET /share/{token}` sent project branding only for single-asset links, so folder, whole-project and multi-item links never had it. It now sends it for every link type, as `logo_url`, `primary_color` and `custom_footer`, each null when the project has not set it. On folder, project and multi-item share pages the project's primary colour now sets the accent (a link's own accent colour still wins) and the custom footer is shown. `logo_url` is now a link the browser can load: it used to be the logo's storage key. A project can also use its cover image as its share logo: set `use_cover_as_logo` with `PUT /projects/{id}/branding`. It is off for every existing project, and with no cover the uploaded logo is used. The response no longer carries `custom_title`, which no share page showed. (#576)
- **The project member list says when each member joined.** `GET /projects/{id}/members` and the add and role-change responses now include `invited_at`. Adding back someone who was removed starts their date again, and records who added them. (#576)
- **A project settings page.** A project's owner opens it from Project Settings in the project card's menu, or from the Settings button in the project toolbar. General holds the project's name, description, visibility and cover image, and its share page branding: a logo, or the cover used as the logo, a primary colour and footer text, with a preview of a share page drawn from the values before they are saved. One Save writes them all and names the part that failed if one does, and following a link out of the page, or closing or reloading the tab, with unsaved changes asks first (the browser's Back button does not). People lists the members with the date each joined, and adds people, changes roles and removes members. Danger deletes the project once you type its name. Each tab can be linked to with `?tab=general`, `?tab=people` or `?tab=danger`. (#577)
- **`POST /projects/{project_id}/folders/ensure` finds or creates a tree of folders in one request.** It takes a `parent_id` (or null for the project root) and up to 500 `paths`, each a list of 1 to 10 folder names, and answers with the folder each path ends in, in request order. Each name reuses the live folder of that name under its parent, or creates it, so sending the same paths again creates nothing new; at the project root, where two folders can already share a name, the oldest one is used. Names are compared after Unicode NFC normalisation and trimming. Paths that would go deeper than 10 folders from the project root are cut to fit, and a path that has no room left answers `folder_id: null`, meaning the parent itself. It needs the editor role, refuses a parent that is not a live folder of the project with `folder_not_in_project` (400), and refuses a request naming more than 2,000 distinct folders with `too_many_folders` (422). Two of these calls for the same project wait for each other, so they never make the same folder twice. The web app uses it to upload folders. (#574)
- **Upload a whole folder, by dropping it or with Upload folder.** Drop a folder on the project page, a folder card or a folder in the sidebar, or choose **Upload > Upload folder**, and its files upload with its folder tree recreated under the folder you dropped it on. A folder that already exists there with the same name is reused, so dropping the same folder again adds to it rather than making a copy. Folders are kept up to FreeFrame's limit of 10 levels, counted from the project root, and files in folders below that go into the deepest folder kept. Only files FreeFrame can upload are sent: hidden and system files (`.DS_Store`, `Thumbs.db`, anything in a `.git` or `__MACOSX` folder) are left out, and other files, such as `.txt` or `.zip`, and empty files are left out with a line in the Uploads panel saying how many and which kinds. Files that cannot be read are counted there too. If the folders cannot be created, nothing is uploaded and the panel says why. Every asset is named after its file without the extension, as uploads already were. (#575)
- **The audio bitrate of video review streams can be set with `TRANSCODER_AUDIO_BITRATE`.** The transcoder never chose an audio codec or bitrate, so every rendition got ffmpeg's default, AAC-LC at about 128 kb/s for stereo, with no way for an instance doing music or sound review to raise it. Set it to a value such as `192k`, `256k` or `320k` (from `64k` to `512k`) and every rendition is encoded as AAC-LC targeting that bitrate. The value is the total for all channels, and ffmpeg's default for a surround source is already higher (about 341 kb/s for 5.1), so a stereo-sized value lowers surround quality. A stream-copied upload uses it for non-AAC audio in place of the fixed `192k`. Left empty, the transcode is exactly what it was. An unusable value is logged and ignored. (#328)
- **Appearance settings in the Create Share Link dialog.** Set a link's layout, card size, aspect ratio, Fit/Fill, card info, sort, theme, accent colour and Open in viewer before you create it, or click **Use my current view** to copy the card settings from the project view. A single-asset link offers only theme and accent, the two its viewer uses. The link panel's preview now shows the link with these settings, in its own theme and accent colour. (#517 by @jdanna)
- **Media can be written in a chosen S3 storage class with `S3_STORAGE_CLASS`.** FreeFrame used to create every object in the bucket's default class, so a provider's other tiers, and lifecycle rules that move objects between them, could not be used. When `S3_STORAGE_CLASS` is set (for example `STANDARD_IA`, or `EXPRESS_ONEZONE` on OVHcloud), the server now applies it to every uploaded original and every transcoder output: HLS renditions, the download MP4, thumbnails, processed images and audio, and watermarked renders. Avatars, logos, project posters and comment attachments stay on the bucket default. The class is set when the server starts an upload, so the browser sends no extra header and the bucket CORS rules do not change. `GLACIER` and `DEEP_ARCHIVE` are refused at startup, because FreeFrame reads media directly and those classes need a restore first. (#514)
### Changed
- **The API runs on SQLAlchemy 2.1, with the psycopg2 driver named in the database URL.** SQLAlchemy 2.1 changed the default driver for a bare `postgresql://` URL to psycopg (v3), which is not installed, so the API now pins psycopg2 for a bare `postgresql://` or `postgres://` URL. A URL that names another driver is left as is, and only psycopg2 is installed. (#538, #544)
- **Project settings and members moved from dialogs to the settings page.** Project Settings in the project card's menu and the members button in the project toolbar open the page (the members button on its People tab) instead of dialogs, and Delete in the card's menu opens its Danger tab instead of the browser's confirm box. Deleting now asks for the project's name and says what happens: the project disappears at once and is permanently deleted after the retention period, with no way to restore it from the app. The members list now shows the errors it used to hide, its remove button no longer needs a hover to appear on a touch screen, and the "Add a message" box, which was never sent, is gone. The old settings page, which no menu linked to and whose every save failed, is replaced, along with its Watermark and Metadata Fields tabs. (#577)
- **The Upload button is a menu of Upload files and Upload folder, and the Upload asset dialog is gone.** What you pick starts uploading at once, as a drop does, into the folder on screen; rename an asset from its row in the Uploads panel. The Upload button in an empty project or folder opens the same menu, and only for roles that can upload. A file whose browser reports no type, such as a `.mov` or `.mkv` on Windows, is sent with the type its extension stands for instead of being refused. Dragging files over the open Uploads panel now closes it, so the drop reaches the page underneath. (#575)
- **Portrait video is transcoded at its own size.** The quality ladder sized every rung as a landscape box, so the 1080p rendition of a 1080x1920 phone clip came out 608x1080 and no rung played at the source's resolution; clips stored landscape with a rotation flag were treated the same way. A rung now names the short side, as it does for landscape video: a 1080x1920 clip gets 1080x1920, 720x1280 and 360x640, and 2160p keeps a 2160x3840 source at 2160x3840. The player and the download menu name a portrait rendition the same way (1080p, not 1920p), and `TRANSCODER_SOURCE_COPY` now applies to upright portrait uploads. Portrait renditions are larger than before, so they take longer to encode and use more storage; portrait videos transcoded before this change keep their renditions, which are now labelled by their short side too (a 608x1080 rendition reads 608p). (#452)
- **One Appearance section for share links.** The link panel's Settings → Appearance uses the same controls as the create dialog: card settings, with Sort By moved in, then a Page group with Theme, Accent Color and Open in viewer. In list layout the grid-only settings (card size, aspect ratio, Fit/Fill) are hidden. An accent that isn't a 6-digit hex is no longer sent, and a new clear button removes the accent. (#517 by @jdanna)
- **Production logs show what the maintenance tasks did, and API requests.** The production compose ran every Celery service at `--loglevel=warning`, which dropped every outcome the scheduled tasks report: what the retention GC hard-deleted, what the orphan sweep removed, and that a sweep was switched off. Gunicorn ran without an access log, so there was no record of requests. The workers now default to `info` (set `CELERY_LOGLEVEL` to change it), and the API logs each request with its query string removed, so the tokens some URLs carry in the query string (`?token=`, `?share_session=`) are never written. Tokens carried in the path are replaced with `<redacted>`: a share link's (`/share/{token}/...`) and an invite's (`/auth/invite/{token}`), since an invite token lets whoever holds it set the account's password. A reverse proxy in front of the API logs its own copy of the path; redact there too if it keeps access logs. What the retention GC, stale-upload reaper and orphan sweep deleted, and a reaper, requeue, retention GC or orphan sweep that is disabled, are logged as warnings, so they still appear at `CELERY_LOGLEVEL=warning`. (#262)
- **Folder, project and multi-item share links open with the comments pane closed.** The header toggle opens it. Single-asset links, and assets opened from these links, still open with comments showing. (#517 by @jdanna)
- **Instance logos and icons keep showing however long a tab has been open.** Each logo was a storage link that expired after an hour, so the app dropped saved logos after 45 minutes, and moving around the app after a long idle could show the instance name beside the default FreeFrame mark. The browser also downloaded every logo again on each page load. `GET /instance/branding` now returns a fixed address for each logo, `/instance/branding/logo/{logo_type}`, which redirects to the file and can be cached for an hour. Re-uploading a logo changes its address, so the new one shows straight away. (#361)
- **The web app moves to Next.js 16.4.2 and React 19.** It was on Next 14.2.35 and React 18. `middleware.ts` is now `proxy.ts` (Next 16 renamed the file; the checks are unchanged), route `params` are read as promises, and `next lint` is replaced by `eslint .` with an ESLint 9 flat config (`apps/web/eslint.config.mjs`), because Next 16 removed `next lint`. No API, environment variable or Docker setting changes. Next 16 needs a newer Node than Next 14 did; `apps/web/package.json` already asked for 20.19+ (or 22.12+, 24+), and the Docker images and CI already use Node 20. (#515 by @jeremy-pixelated)
- **An asset's Share menu lists the links that already show it, each with a copy button.** The menu used to list every share link in the project under "Add to Existing Share Links", with no way to copy one, so resending a link after a new version meant going to the project's Share Links view. It now shows "Links to this asset" first, including folder and whole-project links that show it, each with a Copy button, and "Add to another share link" below with only the links that do not show it yet. Expired links are labelled "Expired", as switched-off links are labelled "Access disabled"; the project's share link list now returns `expires_at` and `is_expired` for each link. Adding reports what happened: "Added to this link", "Already in this link", or the error, where a refused add used to show nothing. This also fixes two bugs in adding an asset to an existing link: the menu no longer says "Asset added!" when the asset was already in the link, and a whole-project or folder link that already shows the asset is left as it is, where it used to be shrunk to that one asset (whole-project) or converted to a link of only the folder and the asset (folder). (#470)
### Fixed
- **A project's branding colours, title, footer and logo can be cleared.** `PUT /projects/{id}/branding` ignored every null it was sent, so once set they could only be replaced. A field sent as null (or an empty string) is now cleared, and a field left out is unchanged; `viewer_layout` and `use_cover_as_logo` cannot be null. A footer over 500 characters, a title or featured field over 255, or a colour that is not a 6-digit hex value is now refused with a 422 naming the field, where some of these failed with a server error. Replacing or removing the logo also deletes the old image from storage, where it used to be left behind. A project logo can also be uploaded from the browser now: `POST /projects/{id}/branding/logo-upload` signed its upload link for the storage server's internal address, which a browser cannot reach, so every upload failed. (#576)
- **A project's description can be cleared.** `PATCH /projects/{id}` ignored `"description": null`, so a description could be changed but never removed. Null or an empty string now clears it. (#576)
- **Share pages show the project's logo.** A share page's header only ever showed the instance logo, since the project logo reached it as a storage key. It now shows the project's logo, or its cover when the project uses the cover as its logo, ahead of the instance logo, on every kind of link and on an asset opened from a folder or project link. On a folder or project link's grid, a signed-in viewer still sees their own account menu in the header instead. A logo that fails to load falls back to the instance logo. The link panel's preview shows the project's logo, colour and footer for every kind of link as well, where it used to take only the colour, and only for single-asset links. (#577)
- **Dropping a folder no longer fails with "The file changed or was moved after it was added."** The browser hands a dropped folder to the page as a single unreadable placeholder named after the folder, and every drop target queued that placeholder as a file, so the row failed at its turn and nothing in the folder was uploaded. Drop targets now read the folder's contents (see Added). An image dragged from the page itself, such as a thumbnail, is no longer taken for a file to upload. **Cancel all queued** now removes all of a large drop's waiting files in one step, where it saved the upload list once per file. (#575)
- **Source copy encodes a video whose rotation it cannot read.** With `TRANSCODER_SOURCE_COPY` on, a rotation value that was present but not a number (for example a `rotate` tag of `abc`) was read as upright and the source was remuxed, which can leave a rotated video lying on its side; a `rotate` tag of `nan` failed the whole transcode on NVENC. Both are now treated as a rotation: the source is encoded, and on a GPU backend it is decoded the way that lets ffmpeg turn it. ffprobe 7.1 always reports rotation as a number, so ordinary uploads are unaffected. (#380)
- **A cancelled upload is always thrown away, and quickly.** Cancel and Remove are the same X in the Uploads panel, so a double click could remove the row before the cancelled upload had stopped; the upload was then never aborted, and the asset, its version and the parts already sent stayed behind until the stale-upload reaper ran (24 hours by default). The abort is now sent whether or not the row is still there. Aborting also gives up on storage that does not answer after about 20 seconds with a "please retry" error, where it used to hold the request for about 5 minutes. (#523)
- **Compare says when a version's stream is unavailable, and its wipe divider and scrubber comment markers work from the keyboard.** If either version's stream failed to load, the image wipe view was an empty black stage with no version badges and no reason, and the image side-by-side panes showed no error either; both now keep the badges, show the version that did load, and show "Stream unavailable for vN" on the failed side. Double-clicking the wipe stage now resets zoom and pan, as side-by-side already did. The wipe divider can be focused and moved with the arrow keys (Shift for bigger steps), Page Up/Page Down and Home/End, and each scrubber comment marker is a button that Tab reaches and Enter or Space opens without also toggling playback. (#170)
- **The watermark re-encode respects `TRANSCODER_CPU_LIMIT`.** `POST /assets/{id}/apply-watermark` runs on the transcoding queue but built its own ffmpeg command, so it took as many cores as ffmpeg chose whatever the limit said, kept a 10-bit source at a profile browsers cannot play, and retried an encode that hit its ten-minute timeout up to three more times. It now takes the same thread budget as a transcode (nothing changes when the setting is unset), writes 8-bit H.264 whenever it re-encodes, and gives up on a timeout instead of retrying. The ten-minute timeout itself is unchanged and now final, so a capped encode of a long video can hit it and fail. (#396)
- **A rate-limited request reaches the browser as a 429 it can read.** The API's global rate limiter and its setup check answered before the CORS headers were added, so a 429 (or the not-set-up 503) arrived without `Access-Control-Allow-Origin` and the browser reported a network error instead, hiding the status and `Retry-After`. Those responses now carry the CORS headers, so the browser can read the 429 and its `Retry-After` (and the setup guard's 503) instead of reporting a network error, and CORS preflight requests are answered before they count against the rate limit. (#525)
- **Switching layout in the share dialog's Settings tab keeps the link's other appearance settings.** It used to reset sort, card size, aspect ratio, Fit/Fill and card info to the defaults. (#517 by @jdanna)
- **A version whose transcode failed keeps its original file.** The stale-upload cleanup used to treat it like an abandoned upload: a day after the upload last moved (`STALE_UPLOAD_TIMEOUT_HOURS`), it deleted the uploaded original from storage and removed the version, though until then the original could still be downloaded. The cleanup now asks storage first and only reclaims a failed version that has no uploaded file, such as a cancelled upload; a failed version whose original is there stays until someone deletes it. (#425)
- **Files left processing no longer use up the rate limit.** The uploads panel checked on each processing or interrupted file with its own request every 5 seconds, so about 50 videos left processing reached the 600-a-minute read limit by themselves and the rest of the app started failing until the minute rolled over. It now asks about up to 100 files in one request, through a new `GET /assets/batch?ids=...` that takes 1 to 100 asset ids, answers like `GET /assets/{id}` for each asset, and leaves out any the caller cannot reach. (#524)
- **Email addresses no longer depend on letter case.** Signing in with a password or a magic code now works however the address is capitalised, and the code goes to the address the account is registered under. An admin can no longer invite `Bob@example.com` next to an existing `bob@example.com`, or next to a deleted user with the same address in another case, and setup applies the same check. New invites and the first admin are stored lowercase. Existing accounts are not rewritten: an instance that already has two accounts differing only in case keeps signing each in by its exact address, and an address that matches both without matching either exactly finds neither. (#471)
- **Branding colours reach hover and focus borders.** With an instance branding colour or a share link accent set, those borders stayed the default blue. (#517 by @jdanna)
- **Share links show the instance logo on the asset view.** Opening an asset from a share link, on its own or from a folder, showed no logo at all, and a guest on a folder link's grid saw the folder's initials instead. The asset view's header now shows the instance logo, and so does the grid's for a guest; a signed-in viewer still sees their avatar menu there. It is the same logo the password screen uses, in the variant for the link's theme, and the FreeFrame mark when no logo is set or the logo fails to load. (#308) When the project has its own logo, that shows instead (see "Share pages show the project's logo").
- **Notifications from share-link guests name the guest.** When a guest without an account mentioned you or replied to your comment through a share link, the notification read "Someone mentioned you". It now shows the guest's name marked as a guest, for example "Jane (guest) mentioned you on Cut 3", and `GET /me/notifications` returns the new `actor_is_guest` field. (#455)
- **Signing in as another account in one tab is no longer undone by a token refresh in another.** A refresh still in flight when another tab signed in wrote its answer over the new sign-in, so both tabs became the old account, or, if the old account's session was refused, signed the new account out of every tab. A refresh now writes nothing once the session in storage has changed, and uses the stored session if it is the same account. (#521)
- **Button focus rings follow the accent colour.** Their old class compiled to no CSS, so they were always a faint stock blue. (#517 by @jdanna)
- **Pending invites can be revoked, and deactivating an invitee withdraws their invite.** Admin → Users offered no way to withdraw an invite, so a link sent to the wrong address stayed valid for its full 7 days, and a deactivated invitee could still open the link, set a password and come out active. Pending rows now have a Resend Invite action that emails a fresh 7-day link and stops the old one working, and a Revoke Invite action that removes the invitee and kills the link, the invite link of a deactivated invitee answers "Invalid invite link", and deactivating clears the link. Reactivating an invitee who never accepted puts them back to pending with a fresh 7-day link to copy, rather than an active account with no password. An invite is accepted on the invite screen only: an invitee who signs in with an email code instead is now told to open their invite link, rather than signed in while still listed as pending, where Revoke Invite would have deleted an account they were using. An invitee who already signed in that way on an earlier release is using a working account: they can still sign in with a code, and their row offers Deactivate, not Revoke Invite. As with Delete, a revoked address can't be invited again. (#472)
- **Bulk Invite shows the name each address will be invited under.** The dialog filled in each display name from the email address without saying so; it now lists every address with that name before you send, and notes that each person sets their own name when they accept. An invite name over 255 characters or only spaces is now refused with a validation error instead of failing with a server error. (#318)
- **Files dropped on a project page before its members have loaded are no longer ignored.** Until the project's members and the signed-in user had loaded, the page treated everyone as a viewer, so a drop made in that moment, or on a slow connection, showed a "not allowed" cursor and did nothing. The drop is now accepted and held until the role is known, then starts for an owner or editor, or shows one "You don't have permission to upload to this project" message for anyone else, including someone viewing a public project they are not a member of. If the members cannot be loaded for another reason, such as a network error, the upload is started and the server decides. (#520)
- **Appearance controls work with screen readers and the keyboard.** In the project view's and share links' Appearance settings, segmented controls report which option is selected, switches are named, and the selects show a focus ring. (#517 by @jdanna)
- **The play/pause button in the mobile comment sheet plays and pauses the media.** On a portrait phone, the button beside the timecode in the comment sheet did nothing and always showed Play, on the asset page and on share links. It now plays and pauses the video or audio and shows its state, and for video it goes through the player's own control, so it gets the same iOS first-tap fix and a pause from the sheet is never undone by a pending retry. (#412)
- **Project owners can delete other people's comments from the comment menu.** The API already let a project owner delete any comment on the project, but the menu only appeared on your own comments, so an owner had no way to remove someone else's or a guest's. Owners now get the menu on every comment in the review screen, the project view's side panel and compare, with Delete and Copy Link. Edit is still offered only on your own comments. (#492)
- **A subfolder name that is already taken is refused with a message instead of a server error.** Restoring a trashed subfolder after a new one took its name, or creating, renaming or moving a subfolder next to one of the same name, used to answer 500 and nothing appeared on screen. It now answers 409 "A folder named '...' already exists here", which the project page shows as a toast, and the trashed folder stays in the trash. Root-level folders can still share a name, as before. (#527)
- **Restoring an item that the retention cleanup has just deleted answers "not found".** If the daily cleanup permanently deleted a trashed asset or folder at the moment someone restored it, the restore failed with a server error; it now answers 404 and the project page says so. (#115)
- **Switching the right version in compare no longer flashes the old sync offset on the new version.** The offsets were cleared in the URL a moment after the new version appeared, so for one to a few frames the new version played with the previous pair's offset. Offsets now carry the version pair they were set for (`offFor` in the compare URL) and apply only to that pair, so they can never show on another one. Links shared before this change have no tag; their offsets apply to the pair the compare view opened on and to no other. (#518 by @jeremy-pixelated)
- **A reloaded or shared compare link opens on the pair it was copied from.** Only the left version was in the compare URL, so after a reload, or for anyone opening a shared link, the right pane fell back to the newest version, a different pair than the one the link was copied from. The compare URL now names the right version too (`right=`), and compare opens on it when it is still ready, also when the link is followed from a review page that is already open. If that version is gone or not ready, the right pane falls back as before (to the newest version on a fresh load) and the URL is updated to name the version actually shown. (#299)
- **"Open in viewer to comment" on the project page opens the viewer.** With an asset selected, the floating "Powered by FreeFrame" badge sat over the right half of the link at the bottom of the right panel, so a click on it opened the FreeFrame repository in a new tab. The panel now keeps the badge's corner clear while the badge is shown. (#526)
- **The sidebar can be used while the Uploads panel is open.** The panel's click-outside layer covered the whole window, sidebar included, so a click on Notifications, Projects or any other sidebar control only closed the panel. It now stops at the sidebar's edge, and Notifications, a sidebar link, or Profile or Settings in the account menu closes the Uploads panel as it opens in its place. (#383)
- **The asset viewer's top bar keeps its back arrow and sidebar toggle clickable at a larger browser text size.** From 1024px wide with the sidebar expanded, a raised text size let the asset navigator cover the back arrow and pushed the sidebar toggle past the right edge of the bar, so neither could be clicked. The "Powered by FreeFrame" credit in the bar now shortens to make room instead. (#384)
- **Background jobs queued just before an API worker stops are still sent.** The API handed each job (a transcode, an email, a cleanup) to its own short-lived background thread, and an API worker shutting down or restarting killed any of those threads that had not reached the broker yet, so the job was silently lost. Jobs now go through a small shared pool of four threads that the worker waits for on shutdown, and a burst of uploads or notifications no longer opens one thread and one broker connection per job. A send to a Redis that stops answering now gives up after about 10 seconds with a logged warning, so it cannot hold up the jobs queued behind it or stop the worker from exiting. The API also no longer asks Celery to keep these jobs' results, which nothing read. (#274)
- **Mixed-frame-rate version comparisons use each video's own paused-frame tolerance.** Comparing, for example, 24fps and 60fps versions no longer repeatedly re-seeks the lower-frame-rate pane when it has already settled within one of its own frames. (#183, #537 by @WhyTan00)
- **An upload whose final request fails for a moment still finishes.** The request that finishes an upload was sent once, so a dropped connection or a brief storage outage at the end of a transfer left the upload Interrupted, and Resume asked for the file again just to repeat that one request. It is now sent again up to twice more, after the wait the server asks for (at most 30 seconds), before the upload is reported Interrupted. (#273)
- **Two versions of one file processing at once each show their own status in the uploads panel.** With an earlier version still transcoding and a new one uploaded on top, the earlier version finishing or failing marked both rows Ready or Failed, and its progress moved both bars, so the new version could read Ready while it was still being processed. Each row now follows only its own version. (#273)
- **Changes to an upload row that came from history are now saved, so they survive a reload.** For a row no tab was sending, such as one listed from upload history, the copy already saved in the browser always won over this tab's newer one, so a refused Discard's "still uploading elsewhere" state or a Discard's error message was never saved and was gone after a reload. The tab's own change is now saved whenever it has changed the row since its last save, so it survives a reload and shows in tabs opened afterwards. Another tab's change still wins over a copy this tab has not touched. (#387)
- **Discard no longer aborts another device's upload right after you cancel a Resume of it.** A Resume cancelled while it was still asking the server where the upload stood counted as this tab sending the upload, so a Discard clicked in that moment skipped the check that another device was uploading it and threw the other device's transfer away. That check now applies to it like any other Discard. (#388)
### Security
- **The web app leaves the Next.js 14 line, which no longer gets security fixes.** 14.2.35 is affected by [GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4) and [GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36) (both critical, fixed in 15.5.24 and 16.3.3), and by [GHSA-2464-fhr4-w9xw](https://github.com/advisories/GHSA-2464-fhr4-w9xw) and [GHSA-p3m5-59mp-jv5r](https://github.com/advisories/GHSA-p3m5-59mp-jv5r) (fixed in 15.5.28 and 16.4.1). 16.4.2 also carries the fix for [GHSA-673q-mjm6-r5xr](https://github.com/advisories/GHSA-673q-mjm6-r5xr), which affects 16.0.0 through 16.4.0. (#515 by @jeremy-pixelated)
### Contributors
Thank you to @jeremy-pixelated, @jdanna and @WhyTan00 (a first contribution) for the pull requests in this release, to @jeremy-pixelated for reporting #412, to @Lennart-Pingpong for reporting #318, #396, #425 and #470, to @sylviiu for the storage-class request in #514, to @loscarinos for the audio-quality request in #328, and to @edvinPL for #492, which is why project owners can now delete other people's comments from the menu.
## [1.20.0] - 2026-10-23
A rebuilt upload experience. Files now upload through a queue instead of all at once, so a large drop finishes whole files rather than leaving many half-sent; uploads belong to the account that added them and survive account switches, sign-outs, files changed on disk, stalled parts and storage outages; the browser warns before you leave a page with uploads in flight, and Log out asks first; and the Uploads panel stays responsive under hundreds of files. This release also fixes a security issue in the sign-in redirect (see Security), and carries fixes to project settings, the notification drawer and failed notification fetches.
### Upgrade notes
- **`NEXT_PUBLIC_UPLOAD_CONCURRENCY` now counts parts for the whole browser tab, not for each file.** If you raised it so that several files at once would upload faster, those files now share that many parts and take turns; a single file uploads as fast as before. Values above 6 only help when the storage endpoint speaks HTTP/2, because over HTTP/1.1 a browser opens at most 6 connections per host. The setting is still read at build time, so changing it needs a web image rebuild. (#535)
- **A bucket lifecycle rule that aborts incomplete multipart uploads after a few days is now recommended.** Starting an upload now gives storage a single try, and a try that times out after storage had already created the upload leaves an empty multipart upload behind. `docs/deployment.md` shows the rule and how to apply it. The rule counts days from when an upload started, so pick a number above the longest upload you expect people to keep resuming. (#535)
- **Reload browser tabs left open across the upgrade.** The API now answers a storage outage during an upload with a coded error that only the new web client reads. A tab still running the old client shows that error's raw text on the row. (#528)
### Added
- **The browser warns before you leave a page while uploads are on their way.** Reloading, closing the tab or navigating away while any of your files in that tab is waiting, starting, sending or finishing shows the browser's own leave-page prompt, so a stray reload no longer stops uploads without a word. It is best effort: desktop browsers show it only after you have interacted with the page, with their own text, and iOS Safari never shows it. Being sent to the sign-in page because the session ended never prompts. (#532)
- **Log out asks first when uploads would stop.** With files waiting or sending in this tab, Log out asks how many will stop ("3 uploads will stop. Log out anyway?") before anything is signed out. Cancel keeps the session and the uploads. The count is this tab's uploads only: Log out signs the account out of every tab, so uploads of the same account in other tabs stop too, their waiting files marked Failed and their sending ones Interrupted. With nothing on its way in this tab, Log out works as before. (#532)
- **Cancel all queued, and Retry on a failed upload.** "Cancel all queued" in the Uploads panel removes every file that has not started and leaves the ones already uploading alone. A Resume still waiting for its turn goes back to Interrupted, its parts kept, rather than being removed. A file the server refuses, for example because the storage limit is reached or its project was deleted, is marked Failed with the reason and a Retry button, offered while the page that added it is still open. Retry, like Resume on an interrupted upload, goes next, ahead of the files that have not started; a Resume waiting for its turn shows "Waiting to resume". (#535)
### Changed
- **Uploads wait in a queue instead of all starting at once.** Dropping or picking many files used to start every one of them at the same moment, splitting the bandwidth between them, so stopping part way left many half-sent files and none finished. Now up to 3 files are active at a time, in the order they were added, and the earliest file gets the bandwidth first, so a stop leaves whole finished files behind it. The queue covers the project page, folder drops, the Upload dialog and New Version. Files that have not started show "Waiting to start" in the Uploads panel and have nothing on the server until their turn, and on an asset's page a waiting new version shows "New version queued, waiting for other uploads". The panel's header counts them apart ("3 active · 2 queued"). If storage or the connection cannot be reached when a file's turn comes, the panel shows "Waiting for storage" or "Waiting for connection", the files behind it wait, and the queue tries again by itself, waiting longer each time up to a minute, and carries on once storage or the connection answers again, without failing any file. A file the server refuses fails on its own and the queue moves on. The screen is kept awake only while files are starting, sending or finishing, so a laptop can sleep while files only wait, which pauses the queue until it wakes. `GET /upload/{version_id}/parts` also answers `server_time`, which a queued Resume uses to tell whether the upload is still running somewhere else. (#535)
- **`NEXT_PUBLIC_UPLOAD_CONCURRENCY` limits the whole browser tab**, not each file; the default stays `5`. See the upgrade note. (#535)
### Fixed
- **Starting an upload while storage is unreachable now answers within about 15 seconds.** It used to hang for minutes and then fail the file with a bare server error. The storage call that starts an upload, for a new file or a new version, is now sent once, with a 5 second connect limit and a 10 second read limit. When storage cannot be reached, times out, cuts the TLS handshake short (a proxy in front of storage that is restarting), or is shedding load or throttling (a 429, or a throttling code such as `SlowDown` or `TooManyRequests`), the API answers 503 with the code `storage_unavailable` and `Retry-After: 5`, which the browser can now read, and the file waits as "Waiting for storage" until storage answers again. `GET /upload/{version_id}/parts` and `POST /upload/complete` answer the same coded 503 when they cannot reach storage, so a Resume waiting for its turn says "Waiting for storage" too. When storage answers and refuses (access denied, a missing bucket, a call it does not implement), or its TLS setup is wrong (a certificate that does not validate, a protocol version mismatch), the API answers 500 with the code `storage_error`, and the file fails with "Storage refused the upload. Ask your admin to check the storage settings." (#528)
- **An upload part that stops moving is now retried instead of hanging.** A part that has sent some of its bytes and then makes no progress for 90 seconds is cancelled and sent again, with the usual retry backoff. A part that has not sent its first byte may only be waiting for a connection, so it is given up on after 90 seconds only when nothing else in the tab has moved either, and otherwise after 5 minutes. A part whose last byte has gone out waits up to 5 minutes for storage to answer. Before, such a part was waited on for good, and its upload sat at the same percentage with no error. Slow uploads are not affected, because the clock restarts with every bit of progress. (#530)
- **A file that was changed, replaced or moved after it was added now fails clearly instead of hanging.** A file picked or dropped into the browser is a handle on the file on disk, not a copy, so changing it before its upload had read it left a stale handle, and Safari hung on it with no error and no progress. Each file is now checked just before it is sent. A file that cannot be read shows "The file changed or was moved after it was added. Add it again.", nothing is created on the server for it, and the queue goes on to the next file. A file that goes away while it is sending, with parts still to send, ends as Interrupted with the same message. (#530)
- **An upload part that the server refuses for good now fails at once with the server's reason.** When the request to send a part was refused because the upload or its file is gone or no longer yours (403, 404 or 409), the panel tried it up to eight times and then offered a Resume that could never work. The upload is now marked Failed with the server's message straight away, without Resume. Lost connections, server errors and rate limits are still retried. (#510)
- **Discard cleans up an upload in storage even when its version is already gone.** When the version no longer exists on the server, for example because its project was deleted, Discard used to drop the row and leave the parts already sent in storage until the overnight cleanup. It now still sends the abort. Uploads started before this release are not covered, because they did not record the storage key Discard needs. Discarding an upload in a project in the trash, or as an uploader who is no longer an editor of the project, aborts it in storage at once and leaves its version to the stale-upload reaper, which removes the version and its asset together; it used to remove the version and keep the asset, so restoring the project showed a card with no versions that could not be opened. (#510)
- **The Uploads panel stops polling an upload whose file was deleted.** If the file was moved to the trash, or its project was deleted, the row used to stay on Processing forever. It now shows "This file is no longer available." and leaves the active list, and Dismiss clears it. A dropped connection or a server error says nothing about the file, so the panel keeps polling through those. (#509)
- **Uploads now belong to the account that added them.** The Uploads panel, its count and the sidebar badge used to show every upload saved in the browser, so after one person signed out and another signed in on the same browser, the second person saw the first person's uploads and could resume or discard them. The panel now shows only the signed-in account's uploads. Another account's uploads are hidden, not deleted, and come back when that account signs in again. Uploads saved by an earlier release are shown to an account only after the server confirms the upload is still that account's to finish. (#531)
- **An upload never lands in an account that signed in during it.** An upload that was sending when the account changed used to carry on with the new account's sign-in. Every request an upload makes is now tied to the account that started it. When another tab signs out, files that had not started are marked Failed with "Signed out in another tab. Add these files again after signing in.", and files that were sending stop as Interrupted, so their owner can resume them later. When the session ends in the tab itself (its sign-in expired or was revoked, or Log out), the files that had not started are marked Failed with "Your session ended. Add these files again after signing in." and saved before the page goes to the sign-in page, so they are listed again after signing back in instead of disappearing. When another account signs in, files that had not started are marked Failed with "Another account signed in. Add these files again after signing back in." Dropping files with no one signed in shows "Sign in to upload" and adds nothing. (#531)
- **Two tabs that press Resume on the same upload at the same moment no longer both send it.** Each could miss the other's start, so both sent every part and one could end Failed with parts missing. Now only one of them sends it, and the other shows it as uploading elsewhere. (#531)
- **Large drops no longer flood the API or slow the page down.** Once one of a project's uploads had finished in the browser, every status change of every upload in it refetched the project's asset grid, two reads each, and every status change re-rendered the whole project page, and every progress update re-rendered every row of the Uploads panel. A drop of a few hundred small files could use up the account's limit of 600 API reads a minute, after which the page's reads were refused until the minute ran out, and kept the browser busy for most of the drop. While uploads are on their way the grid now refetches at most once every 2 seconds, and at once when the last of them finishes, so a single small upload still shows Ready as soon as it is; a status change no longer re-renders the page, and a progress update re-renders only its own row. (#533)
- **A finished upload's time in the Uploads panel counts from when it finished.** It counted from when the file was added, so a file that waited in the queue, or an old upload resumed today, said "25 minutes ago" or "3 days ago" the moment it finished. (#533)
- **Project settings keep a chosen poster and unsaved edits while open.** Choosing a poster used to flash the preview and then drop it on return from the file picker, so Save uploaded nothing; name and description edits were lost the same way. Reopening the dialog now starts from the saved project. (#465, #512 by @jay23606)
- **The notification drawer no longer covers an expanded sidebar.** (#513 by @jay23606)
- **A failed notifications fetch no longer throws an uncaught error.** Opening Notifications in a tab whose session had ended in another tab, or any failed `GET /me/notifications` (offline, 429, 500), logged `Uncaught (in promise) ApiError` and showed the Next error overlay in development. The notifications store now handles the failure, keeping the current list and unread count and clearing the loading state. (#522, #534 by @ColumbusLabs)
### Security
- **The sign-in page could be used to send a visitor off-site or run script in the app.** A crafted `/login?from=...` link could redirect a signed-in visitor to another origin, or run script in the app through a `javascript:` target. The page now follows `from` only when it is a path inside the app. ([GHSA-vq88-94mj-28ww](https://github.com/Techiebutler/freeframe/security/advisories/GHSA-vq88-94mj-28ww), #519)
### Contributors
Thanks to @jay23606 and @ColumbusLabs for contributions in this release.
## [1.19.0] - 2026-10-19
Security release: fixes [GHSA-w8vh-2j74-m3hw](https://github.com/Techiebutler/freeframe/security/advisories/GHSA-w8vh-2j74-m3hw), which left a deleted project reachable through its share links and to its members until the retention purge. Upgrading is strongly recommended. Also right-click menus on projects, assets, folders and share links, comment counts on project thumbnails, and fixes to the player's back arrow and the Projects list view.
### Upgrade notes
- **Requests into deleted content are now refused.** Share links into a deleted project, or into a deleted folder or asset, answer 404 *Share link not found* on every share endpoint, where they used to keep working. For signed-in users a deleted project now answers 404 *Project not found*, as `GET /projects/{id}` already did, on the routes that reach it by its id or by the id of one of its folders, assets, comments, uploads or share links: listing its assets, folders, folder tree and trash; creating, renaming, moving, deleting and restoring its folders and assets; new versions, approvals, assignments, metadata, collections, branding and watermark settings; creating, listing, editing and revoking its share links and direct shares; editing or deleting a comment or an attachment; and presigning, resuming or completing an upload. Restoring a folder or asset in a deleted project answers 404 where it answered 409. Opening, streaming, downloading or commenting on one of its assets answers 403 *Access denied*. Aborting an upload still works, but an upload in a deleted project is always recorded as failed, even when its file is already complete, and is never transcoded. `GET /me/notifications` leaves out notifications about an asset in the trash or in a deleted project, and shows them again if the asset is restored. Segment URLs in a video stream's playlist now expire with the stream token instead of 24 hours after each playlist fetch. On the same routes a project id that does not exist now answers 404 *Project not found* where it answered 403 *Not a project member*; a live project you are not a member of still answers 403. (#507)
### Added
- **Right-click menus on projects, assets, folders and share links.** Right-click (or long-press on a touch screen) opens the same menu as the item's ⋯ button: on project cards and rows, asset cards and rows, folders in the grid, the list and the sidebar tree, and share links in the sidebar and the Share Links table. Right-clicking an item that is one of several selected opens a menu for the whole selection, with the selection bar's actions: Create Share Link (the bar's Share), Download, Move to and Delete. A project's menu also gains Open in New Tab and Copy Link, since it takes the place of the browser's. (#502 by @jdanna)
- **Comment counts on project thumbnails.** Assets in the project grid show how many comments their latest version has, with the same badge as a share link's grid, bottom-left on the thumbnail. Replies count, and internal comments do too, as the comment panel shows them to project members. The project asset list adds `comment_count` to each asset. (#504 by @jdanna)
### Changed
- **One folder menu everywhere.** A folder's menu is the same in the sidebar tree, the grid and the list: Create Share Link, New Subfolder, Rename, Delete. The tree gains Create Share Link, and the grid and list gain New Subfolder. Create Share Link and New Subfolder are offered to owners and editors only, as the toolbar's buttons are. (#502 by @jdanna)
### Fixed
- **The sidebar tree's folder menu closes when you click elsewhere or press Esc.** It used to stay open until one of its items was picked. (#502 by @jdanna)
- **The selection bar goes away after a bulk delete.** Once the deleted items are gone from the page, they leave the selection too. It used to stay up, counting the items that had just been deleted. (#502 by @jdanna)
- **Rename dialogs open ready to type.** The name field is focused with the whole name selected, so typing replaces it. Renaming an asset used to leave the cursor after the old name, and opening the dialog from a menu could leave focus outside the field. (#502 by @jdanna)
- **Rename and Delete on a folder in list view.** Rename changed nothing (it sent back the folder's current name) and now asks for the new one, as the grid does. Delete removed the folder at once and now asks first. (#502 by @jdanna)
- **The player's back arrow returns to the asset's folder.** It went to the project root from anywhere, so leaving an asset inside a folder dropped you out of that folder. A share link's back arrow already did this. (#503 by @jdanna)
- **Project pictures in the Projects list view.** The list showed the same purple gradient for every project. Each row now shows the project's poster, or its own gradient, the same as the grid. (#505 by @jdanna)
### Security
- **Share links stop working when what they share is deleted.** Deleting a project marks it deleted and leaves its folders, assets and share links as they were until the retention purge removes them (`SOFT_DELETE_RETENTION_DAYS`, 30 days by default, never when it is 0 or less). Share links only ever checked the link itself, so for that whole time an asset, folder, project or multi-item link into the deleted project still listed its contents, streamed and downloaded media, showed comments and took guest comments. A link now works only while its project, its asset or folder, and every folder above that are live. Otherwise every share endpoint answers 404 *Share link not found*, exactly as for a link that does not exist, whether or not the link is disabled, expired or password protected. A delete does not change the link, so restoring a folder or asset brings its links back. A deleted subfolder can no longer be opened through its parent folder's link either. Signed-in access to a deleted project ends the same way. Its members could still list its assets and folders, edit, move, approve and add versions to its assets, change its branding, metadata and collections, and create and manage its share links, and a comment's author could still edit or delete the comment. Those routes now answer 404 *Project not found*, as `GET /projects/{id}` did, and aborting an upload no longer sends a complete upload in a deleted project on to transcoding. Project members, people an asset was shared with directly, and an asset's creator can no longer open, stream, download or comment on its assets (403), and `/me/assets` and `/me/folders` leave them out. `/me/notifications` leaves out notifications about them too, which named the asset and quoted the comment. Stream and download links handed out before the delete still run until they expire: 1 hour for downloads and other media, and 24 hours for video. The video stream proxy checks only its token, and the segment URLs it signed lasted 24 hours from each playlist fetch, so video could keep playing for up to about 48 hours after the token was issued; they now expire with the token. (#507)
### Contributors
Thank you to @jdanna for the pull requests in this release.
## [1.18.0] - 2026-10-16
Profile pictures, a choice of original or transcode on download, Projects page view options, larger upload parts with a Finalizing state and upload speed, and selection shortcuts. On NVIDIA GPUs, portrait phone videos now come out upright and very tall or wide videos no longer fail to transcode.
### Upgrade notes
- **Three migrations** add `instance_settings.upload_part_size_bytes` (default 0, automatic sizing; #483), `users.avatar_color` (everyone starts with no color, which looks the same as before; #487), and `instance_settings.allow_original_downloads` (default off) with `share_link_activity.rendition` (#493). (#483, #487, #493 by @jdanna)
- **Portrait phone videos already transcoded on an NVIDIA or Intel GPU stay sideways.** The fix applies to new transcodes; upload an affected video again to get upright renditions. (#500)
- **Downloads can now be the original upload, not just the transcode, and that changes who can pull camera masters.** Project members can pick **Original** from any Download menu. Everyone else (share-link visitors, including folder, project, multi-item, password and secure links; non-members of a public project; people an asset is shared with directly) still gets only the transcode, exactly as before, until an admin sets **Admin Dashboard → Instance settings → Downloads** to *Original or transcode*. With it off, `?rendition=original` is refused for them with a 403. A video with no MP4 transcode still downloads its master, as it always has. **Bulk downloads follow the same setting for everyone, members included:** downloading a selection on the project page, or **Download All** on a share link, fetches every asset's original when the setting is on and the transcode when it is off. Turning it on can raise storage egress considerably. (#493 by @jdanna)
### Added
- **The uploads panel shows each upload's speed and time left**, right-aligned on the line with its percentage. Speed is smoothed over the last few seconds and drops toward zero if the transfer stalls. (#483 by @jdanna)
- **Upload part size in Instance Settings.** In Admin Dashboard → Instance settings, admins can keep Automatic sizing or cut every upload into a fixed part size from 5 to 512 MB. With a fixed size, a file that would need more than 10,000 parts is refused before anything is uploaded, with a message naming the largest file that size allows. (#483 by @jdanna)
- **Selection shortcuts on the project page.** ⌘-A (Ctrl-A on Windows and Linux) selects every asset and folder shown. Shift-click selects every folder and asset between the last one you clicked and this one, and ⌘-click (Ctrl-click) adds a folder or asset to the selection or takes it out. Both add to what's already selected, and neither opens a folder or changes which asset the side panel shows. Shift-click on a checkbox selects a range too. Esc deselects everything, including the asset in the side panel. In a text field, dialog or menu, ⌘-A and Esc keep their usual meaning. (#488, #497 by @jdanna)
- **Choose what a download is: the original file or the transcode.** The Download button on the project page, in share links and in the asset menus now opens a list with **Original**, the file exactly as uploaded, and the transcode where one exists: a video's MP4 (labelled with its resolution, such as 1080p), an audio file's MP3 or an image's WebP. Each entry shows its format and size. Only files that already exist are listed, and nothing new is transcoded. Project members always get both choices; for everyone else Original is listed only when an admin allows it in **Admin Dashboard → Instance settings → Downloads** (off by default). Bulk downloads (a selection on the project page, Download All on a share link) get originals when that setting is on and transcodes when it is off. An MP4 upload's MP4 transcode downloads with its resolution in the name (`Portrait (720p).mp4`) so the two don't collide, and share-link activity records which file each download was. The API adds `GET /assets/{id}/downloads` and `GET /share/{token}/downloads/{asset_id}` (rate-limited like opening a link), and `rendition` and `prefer_original` parameters on both stream endpoints. (#493 by @jdanna)
- **Press `?` for a list of keyboard shortcuts.** The panel existed but was never shown, and it listed several shortcuts that did nothing (⌘/, M, F, C and the N/G sequences). It now opens from any signed-in page and lists only shortcuts that work. (#488 by @jdanna)
- **Appearance and sort menus on the Projects page.** Choose grid or list, and in grid a small, medium or large card size and whether cards show their info row. Sort projects by date created or by name, as you can inside a project. Your choices are saved to your account, so the page opens the way you left it on any device; it used to reset to the grid every visit. (#485 by @jdanna)
- **Profile pictures.** Upload, replace or remove one under Settings → Profile. The picture is cropped to a square and scaled down to 256px in the browser before it's uploaded, so a phone photo doesn't become a multi-megabyte download on every comment. It shows wherever you appear: comments and replies (including on share links), timeline comment markers, the compare view, assignees, project members, the sidebar, Admin → Users, and the top bar of a folder share link you open while signed in. Deleting a user removes their picture. A refetch no longer makes pictures flash back to initials. Comments and markers used to show initials only, even for someone who had a picture. (#487 by @jdanna)
- **A circle color for people without a picture.** It's set under Settings → Profile, next to the picture, and replaces the default color behind your initials everywhere they're shown. Reset goes back to the default. (#487 by @jdanna)
### Changed
- **Large uploads are cut into fewer, larger parts.** Every upload used to go up in 10 MB parts, so a 50 GB file was 5,000 of them, and completing an upload makes the storage backend read back and assemble every part. Files up to about 5 GB still use 10 MB parts; bigger files now get parts sized to hold about 500, up to 256 MB each. The largest file an upload can hold rises from about 97.7 GB to about 2.4 TB. Uploads already in progress keep the size they started with. (#483 by @jdanna)
- **The share dialog stays open after you create a link.** It used to close at once, so you had to find the new link in the Share Links table. Now Create turns into a Copy button in the same spot, so you can click Create and then Copy without moving the mouse. Copy confirms with "Copied!". The link, Copy and Close (in place of Back) sit on one line, and the settings lock because they've already been applied. The dialog doesn't change size, so nothing shifts. (#494 by @jdanna)
- **The project's Share Links list is newest first.** It had no defined order before. The `/projects/{id}/share-links` response now includes each link's `created_at`. (#494 by @jdanna)
### Fixed
- **An upload that has sent every byte says "Finalizing" instead of sitting at "Uploading 95%".** The storage backend can take minutes to assemble a large upload, which read as a hung upload. Cancel is hidden for that stretch, since there is nothing left for it to stop, and comes back if a part fails and has to be sent again. The API also logs any completion that takes 5 seconds or more, split into listing, assembling and finishing time. (#483 by @jdanna)
- **Prev/next in the asset viewer stays in the asset's folder and follows your sort order.** It used to step through every asset in the project, in whatever order the server returned them, so from inside a folder it jumped out into other folders. It now walks the same list the project view shows for that folder (or the project's top level), in the view's current sort, and the "2 of 7" count matches it. (#495 by @jdanna)
- **Anamorphic videos get correctly proportioned thumbnails.** The poster frame was saved at the source's storage size, and JPEG keeps no pixel aspect a browser honours, so a 1440x1080 HDV clip or a 2x-squeezed scope master came out squeezed. Thumbnails are now scaled to square pixels and fit within 1280x1280, keeping the clip's own shape, so a 4K source no longer makes a 4K JPEG. Existing thumbnails change only when the asset is transcoded again. (#478, #480 by @jdanna)
- **Projects without a poster show their colour gradient instead of a plain grey card.** The gradient classes live in `lib/`, which Tailwind did not scan, so seven of the eight presets were never generated. (#480 by @jdanna)
- **Videos you open after watching another start in Auto at the quality your connection carried.** Before its first segment loads, the player guesses your bandwidth from the top rendition, capped at 5 Mbps. Whenever a rung peaks above that, playback began below it and climbed only after a few seconds, even on a link that could carry it. It now starts from the bandwidth this browser measured on a playback in the last day, capped at 20 Mbps. The first video in a browser starts as before. (#486 by @jdanna)
- **Sorting by date says Newest or Oldest instead of Z-A or A-Z.** The sort menu inside a project labelled the date direction as if it were alphabetical. (#485 by @jdanna)
- **Picking a sort inside a project starts it in a sensible direction.** Date starts newest first and everything else A-Z; switching from Date to Name used to carry the direction over and list names Z-A. (#485 by @jdanna)
- **Sorting by name orders numbers by value.** "Shot 2" now comes before "Shot 10". (#485 by @jdanna)
- **The share links list shows each link's own title.** Asset and folder links were listed under the name of the asset or folder they share, so a link you had named "Client cut" on a folder called "F" showed up as "F", and searching for it by its name found the right row under the wrong label. Project links were not affected. (#498)
- **A share link set to expire on a date lasts to the end of that day, in your time zone.** The picked date was read as midnight UTC, so west of UTC a link expired the evening before (picking 20 October in New York ended it at 8:00 PM on the 19th for a single item, 11:59 PM on the 19th for several), and east of UTC a single-item link expired in the early morning of the day itself. Every place you set an expiry now uses the same rule, and the date field shows the day you picked rather than the UTC one. Links saved before this keep the moment they were stored with, so one may now show the day before the date you picked. (#498)
- **Copying a share link over plain HTTP copies the link.** On an instance served without HTTPS (a LAN address, for example) the browser has no clipboard API, and the fallback copied whatever was selected. Inside the share dialog the dialog took focus back before the copy ran, so the button said "Copied!" while the clipboard got nothing. The fallback now hands the browser the text directly, and says "Failed" when the copy did not happen. (#498)
- **A new share link for an asset, a folder or the whole project is created with its settings in one step.** The dialog created the link first and applied its passphrase, expiry, visibility and permissions in a second request, so if that request failed the link was already live without them, for example open to anyone with the URL and no passphrase. Multi-item links already worked this way. (#498)
- **Tall portrait and very wide videos transcode on NVIDIA GPUs.** On an NVENC host, a video narrower than about 0.4:1 (720x1920, or a 904x2316 phone cover-screen recording) or wider than about 13:1 (8000x480) failed to transcode, though a CPU-only host handled it. Its 360p rendition came out smaller than the GPU's H.264 encoder accepts (146x50), and that error did not fall back to software. Such a rendition is now made just big enough for the encoder, in the video's own shape, so the job stays on the GPU: a 720x1920 video's 360p rendition is 146x390 instead of 136x360. Any rendition NVENC still refuses, such as one smaller than that in a tiny source or wider than 4096 pixels, is now encoded in software instead of failing the asset. HEVC output (`TRANSCODER_OUTPUT=h265_10`) failed the same way below about 0.36:1 and is fixed the same way. (#477, #499)
- **Portrait phone videos come out upright on NVIDIA and Intel GPUs.** A phone records portrait video as landscape frames plus an instruction to turn them, and ffmpeg only turns frames that are in system memory. On an NVENC or VAAPI host the GPU decoder kept its frames on the GPU, so a portrait clip got landscape renditions lying on their side (upside down for a clip shot the other way up), and its download came out the same, next to an upright thumbnail. On NVIDIA such a video is still decoded on the GPU, but its frames now pass through system memory to be turned before the GPU scales and encodes them, which costs a little CPU. On Intel, which we could not test, it is decoded on the CPU and still scaled and encoded on the GPU. Videos without a rotation, and CPU-only hosts, are unchanged. Videos transcoded before this fix keep their sideways renditions until they are uploaded again. (#500)
### Contributors
Thank you to @jdanna for the pull requests in this release and for reporting #478, and to @Paracid for the request in #462 that the original-download option answers.
## [1.17.1] - 2026-10-14
Security release: fixes [GHSA-q5pw-99q5-vp8x](https://github.com/Techiebutler/freeframe/security/advisories/GHSA-q5pw-99q5-vp8x), which let any signed-in user read or delete files in the storage bucket through avatar and project-logo keys. Upgrading is strongly recommended.
### Upgrade notes
- **Two migrations** clear avatar values, and project and instance branding logo values, that do not point at a file uploaded for them. Those users show their initials and those logos fall back to the default; uploaded avatars and logos are kept. (#490)
### Fixed
- **WAV files uploaded from Safari, and AIFF files from any browser, now upload.** The upload allowlist knew one spelling per format, but the browser picks the MIME type and they disagree: Safari sends `audio/x-wav` for a `.wav` where Chrome sends `audio/wav`, and both send `audio/vnd.wave` for a `.bwf`, so those uploads stopped at "Unsupported file type". The allowlist now takes the spellings browsers actually send for WAV, BWF, AIFF, FLAC and M4A, and for MKV, AVI and M4V from Chrome or Safari. Nothing downstream reads the type, so these files process like any other. (#484 reported by @rm95sdbgtk-a11y, #489)
### Security
- **Profile avatars are limited to pictures the user uploaded.** An account could set its avatar to a storage key it did not upload, which could expose or remove other files in the bucket. Now only avatars uploaded through the avatar upload step are accepted, signed and replaced, and `PATCH /users/{id}` ignores `avatar_url` (the web app never sent it). Existing values that don't point at an uploaded avatar are cleared. The same rule now applies to project branding logos and to the instance logos and icons: only a key issued by that logo's own upload step is accepted, signed or deleted, and existing values that don't match are cleared. (#490)
### Contributors
Thank you to @rm95sdbgtk-a11y for reporting #484.
## [1.17.0] - 2026-10-12
Includes the fixes for two security advisories about the live-update stream: [GHSA-mq5v-rvfx-6mmh](https://github.com/Techiebutler/freeframe/security/advisories/GHSA-mq5v-rvfx-6mmh) and [GHSA-85cc-6p7r-ppc4](https://github.com/Techiebutler/freeframe/security/advisories/GHSA-85cc-6p7r-ppc4). Upgrading is recommended.
### Upgrade notes
- **Two new optional settings**, `SSE_AUTH_MAX_AGE_SECONDS` and `SSE_AUTH_RECHECK_SECONDS`, tune how often an open live-update stream re-checks access. The defaults (1 and 15 seconds) need no change. (#481)
- **The API refuses to start with an empty `JWT_SECRET`, or a `JWT_ALGORITHM` other than HS256, HS384 or HS512.** An empty secret used to let anyone sign a valid token, and the new token library rejects it on every request. An RS or ES algorithm only ever worked with an undocumented PEM key in `JWT_SECRET`, and now fails on every request. A `JWT_SECRET` shorter than 32 bytes still works but now logs an `InsecureKeyLengthWarning`; replace it with `openssl rand -hex 64` when convenient, which signs everyone out once. (#474)
- **The bundled S3 server is now Silo, a maintained fork of MinIO.** The `minio/minio` and `minio/mc` images can no longer be pulled from Docker Hub, so the dev stack failed on any machine that did not already have them cached, and so did the optional self-hosted storage block in `docker-compose.prod.yml`. Both now pin `pgsty/silo` and `pgsty/mc` (`RELEASE.2026-09-16T00-00-00Z`). Silo reads the same `MINIO_*` variables and opens an existing `miniodata` volume as is (checked against data written by the MinIO release we pinned before), and the service keeps the name `minio`, so `S3_ENDPOINT=http://minio:9000` does not change. If you run the prod block, copy the new `image:` lines into your compose file. One behaviour differs: Silo enforces the bucket CORS rules FreeFrame writes at startup, where MinIO ignored them and allowed every origin. A browser origin that is not `FRONTEND_URL` or `http://localhost:3000` now needs to be in `CORS_ALLOW_ORIGINS`, or its uploads and playback are refused. In production the API shares the web app's origin, so a `FRONTEND_URL` still left at the template's `http://localhost:3000` went unnoticed until now: with Silo it blocks every upload, so set it to the exact origin your browser uses. If your `.env` sets `MINIO_CORS_ALLOW_ORIGIN`, move that value to `CORS_ALLOW_ORIGINS`. Step-by-step in docs/deployment.md (Updating). (#461)
- **One migration** adds `instance_branding.default_login_mode`. Existing instances keep opening the sign-in page on magic code. (#464 by @jdanna)
### Added
- **Delete users from Admin → Users.** Once a user is deactivated, a Delete button appears next to Reactivate and asks for confirmation. Deletion is a soft delete: the user leaves the list and can't sign in, and their comments and activity stay. The API only deletes users that are already deactivated, and a deleted user's email can't be invited again. (#464 by @jdanna)
- **Default sign-in method under Admin → Instance Settings.** Choose whether the sign-in page opens on magic code or on email & password. People can still switch to the other method from the sign-in page. (#464 by @jdanna)
### Changed
- **Admin → Instance Settings saves each setting as you change it.** The sign-in method saves when you pick it, and the storage limit when you leave the field or press Enter, so the Save button is gone. Enter 0 for unlimited; clearing the field and clicking away keeps the current limit instead of removing it. (#464 by @jdanna)
- **The upload progress bar moves while a part is being sent.** It used to count finished 10 MB parts, so on a slow uplink it stood still for minutes at a time and looked stalled. Parts are now sent with `XMLHttpRequest` instead of `fetch`, and progress is counted in bytes. The bar still updates at most once per whole percent, never goes past 95% before completion, and does not jump back when a part is retried: it holds until the retry has caught up. A resumed upload starts from the bytes the storage backend already holds. Retries, cancelling, resume and the parallel part uploads are unchanged. Every finished part still marks the upload as live for other tabs, as in #312, and the part PUT is still sent without a `Content-Type`. (#313, #469 by @Lennart-Pingpong)
### Fixed
- **Origins in `CORS_ALLOW_ORIGINS` can now upload and play media on a non-AWS store, not only call the API.** The bucket CORS rules set at startup allowed only `FRONTEND_URL` and `http://localhost:3000`, so on a store that enforces them (Garage, Silo) an extra origin could sign in and then failed on every upload and every HLS segment. The API and the bucket now read one origin list, and `*` becomes one rule allowing any origin. An entry S3 can't take as an origin (a `?`, or more than one `*`) is left out of the bucket rules with a warning, since S3 rejects the whole configuration over it. (#461)
- **`.env.example` points the database, Redis and S3 at their compose service names instead of `localhost`.** The production compose takes these values from `.env.prod` without overriding them, so a deployment that followed the template had the app containers dialing themselves and failing with `connection to server at "localhost" ... refused`. With `S3_STORAGE=s3` the old `S3_ENDPOINT=http://localhost:9000` also stopped the API at startup; the new value is the built-in default, which AWS mode accepts. (#460)
- **The `MINIO_CORS_*` variables are gone from the compose files, `.env.example` and the README.** MinIO never read those names, so they changed nothing; the bucket's CORS comes from FRONTEND_URL and CORS_ALLOW_ORIGINS. (#461)
- **The project page no longer refetches its assets and folders on every upload or transcode progress update.** Whenever the uploads panel listed a finished upload for the project, which the upload history often does, every progress update fired two requests. One video's transcode progress cost about 200 of them, and a few at once could reach the per-user limit of 600 reads a minute. The page now refetches only when an upload changes status. The sidebar and the dashboard shell also stop re-rendering on every progress update. (#473)
- **The quality picker stays on Auto when HLS changes rendition automatically**, and shows the active rung next to Auto. (#423, #459 by @sb123sb123)
- **Inviting an email that belonged to a deleted user now explains why it can't be invited**, instead of failing with a server error. (#464 by @jdanna)
- **Reset all branding** no longer shows on instances that have no branding configured. An unset accent color counted as custom, so the section appeared even if no custom branding was set. (#464 by @jdanna)
- **ProRes and other sources the GPU can't decode now still encode on NVENC and VAAPI.** The GPU decoders take no ProRes, DNxHR, 10-bit H.264, or 4:2:2 H.264 or HEVC. The hardware attempt failed on these with `function not implemented`, and the whole transcode then ran on the CPU. Such sources are now decoded on the CPU and uploaded, so scaling and encoding stay on the GPU. A source that turns out not to decode on the GPU gets the same treatment on retry, and any other hardware failure still ends on the software pipeline. Any other failure on the CPU-decode step also falls back to the software pipeline, so a source that is genuinely broken is tried once more there before the asset fails. The fallback log now names the line of ffmpeg's error output that says why, where it can. A GPU that has gone missing at runtime, such as one that fell off the bus or a driver upgraded without a reboot, now drops these sources and HDR sources to the software pipeline instead of failing the asset. (#475 by @jdanna)
### Security
- **Web build and test dependencies moved past four advisories.** `brace-expansion` to 1.1.21, 2.1.7 and 5.0.12 ([GHSA-q2hr-2g5m-vwhr](https://github.com/advisories/GHSA-q2hr-2g5m-vwhr)), `source-map-js` to 1.2.2 ([GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q)), `undici` to 7.30.0 ([GHSA-w293-vg96-wgc3](https://github.com/advisories/GHSA-w293-vg96-wgc3) and five lower-severity advisories) and `postcss-selector-parser` to 7.1.6 ([GHSA-rj75-hqrm-r3gf](https://github.com/advisories/GHSA-rj75-hqrm-r3gf)). All four reach the app only through eslint, Tailwind, PostCSS and jsdom, so nothing in the browser bundle changes. `postcss-selector-parser` has no fixed 6.x release, so Tailwind 3 is now handed 7.1.6: the production stylesheet is byte-for-byte the same as before, built both with pnpm and with the dev stack's npm, and the `group-hover` / `peer-hover` test from #435 passes. The pinned `brace-expansion@1` override was also what kept Dependabot from opening this fix itself. The `next` advisories need the move to Next 15 and are not covered here. (#467)
- **The API now signs and checks its tokens with PyJWT instead of python-jose.** python-jose 3.5.0 is affected by [GHSA-3qf3-8w2g-rqmx](https://github.com/advisories/GHSA-3qf3-8w2g-rqmx) and has no fixed release: its guard against a public key being used as an HMAC secret misses DER-encoded keys, so an app that checks tokens against an RSA or EC public key while also accepting HS256 can be handed a token forged with that key. FreeFrame was not exploitable, because every token it issues (access, refresh and HLS stream) is signed and checked with `JWT_SECRET`, never a public key, and only the one algorithm in `JWT_ALGORITHM` is accepted. The swap removes python-jose, `ecdsa`, `rsa` and `pyasn1` from the API image; PyJWT was already installed as a dependency of `redis`. Share-link passwords use `cryptography` directly, which used to arrive only through python-jose, so it is now pinned at 50.0.2, the version builds were already installing. Tokens issued before the upgrade are still accepted after it, so nobody is signed out and open stream links keep playing, and a rollback keeps them valid too. A malformed bearer token with a deeply nested header now gets a 401 instead of a 500. (#474)
- **A failed transcode no longer passes on the presigned link to the uploaded file.** ffmpeg and ffprobe print the URL they read from, and a probe or encode that times out quotes it too, so the error in the `transcode_failed` event carried a signed link to the raw upload, valid for up to two hours, and the S3 access key ID. That event reaches every member of the project, and any signed-in user when the project is public. The same text went to the worker log, the Celery result backend in Redis, the keyframe-probe log line when `TRANSCODER_SOURCE_COPY` is on, and the metadata backfill's warnings. The signed query string is now replaced with `?<redacted>` wherever ffmpeg's output or a timeout becomes an error, and again before the event is published. The object path stays, so the failing file can still be found. The people the event is meant for could already open that asset. (#476)
- **Live project updates now stop within seconds of someone losing access to the project.** The event stream behind live comment, approval and transcode updates checked access only when it connected, and an open stream kept delivering for as long as the connection stayed up. It now checks again while it is open: before it delivers an event, at most once a second, and every 15 seconds while it is idle. When the user has been removed from the project, deactivated or deleted, when their sign-in has been ended by a password change, or when the project has been deleted or made private and they are not a member, the stream sends a final `revoked` event and closes. It also closes as soon as the access token it was opened with expires, and sends nothing after that. A new connection in any of those cases is refused with a 403; a deleted project used to accept one. The web app stops listening when access itself was taken away, and gets a fresh token and reconnects when only the token was the problem. The stream is also lighter on the server while it is open. `SSE_AUTH_MAX_AGE_SECONDS` (default 1) and `SSE_AUTH_RECHECK_SECONDS` (default 15) tune the two intervals. An idle stream gets a keepalive at the recheck interval or every 30 seconds, whichever is shorter, so a longer recheck interval does not leave a quiet stream for a proxy to drop. (#481)
### Contributors
Thank you to @jdanna, @Lennart-Pingpong and @sb123sb123 for the pull requests in this release, to @Lennart-Pingpong for reporting #313, and to @moorsey for reporting #460 and #461.
## [1.16.0] - 2026-10-05
### Upgrade notes
- **No migrations, and no new settings you must set.** The default quality ladder is unchanged; 2160p is opt-in.
- **EDL exports of footage with embedded timecode now start at that timecode,** not at `01:00:00:00`. Pass `?start_tc=01:00:00:00` to get the old start. The export probes the uploaded master with ffprobe, which is already in the API image.
- **Restoring a folder no longer restores items that were trashed before the folder was.** They stay in the trash with their own retention clock.
- **Replies through a share link now create in-app notifications** for the parent comment's author, and no reply notification is sent to someone who has lost access to the asset.
- **Known issue:** portrait video is fitted into a landscape frame by every rung, so it comes out below its source resolution, including under `2160p` (#452).
### Added
- **A 2160p rung for the quality ladder.** `TRANSCODER_QUALITIES` now accepts `2160p` (3840x2160). It is opt-in, so the default ladder is unchanged. Set on its own, it gives one rendition at the source's own size for anything up to 4K, because a rung above the source is built at the source's size instead of being dropped: a 720p master comes out at 720p, a 1440p one at 1440p, and a 6K one is fitted into 3840x2160. With `TRANSCODER_SOURCE_COPY=true`, an H.264 8-bit master at any of those sizes is packaged without re-encoding. A 4K encode costs about four times the CPU of a 1080p one, so on an instance that receives HEVC, 10-bit or ProRes 4K masters a GPU backend is recommended. (#451, #453)
### Fixed
- **Replies posted on a share link are saved.** The share review screen handed the comment panel a no-op reply handler, so a reply cleared its box and sent nothing. Folder and project links have had this since v1.0.0, and single-asset links since v1.13.0, when they moved onto the same review screen. Replies now go through the same path as top-level comments, with `parent_id` set, and appear once the thread refetches. A guest without a name yet is asked first, and the typed reply waits in its box instead of being lost. A failed reply keeps its text and says why. On a view-only link, Reply is no longer offered. (#439, #443 by @oliverstreetcreative)
- **The project page's comment sidebar had the same dead reply box.** It now posts through `useComments().createComment` with the parent. (#439, #443 by @oliverstreetcreative)
- **`POST /share/{token}/comment` checks a reply's parent.** The parent must be a live, non-internal comment on the asset the link resolves to. Before, an unknown `parent_id` answered 500, and a parent on another asset, a deleted parent, or an internal one was accepted. A share-link reply also now takes its parent's version, as `POST /assets/{id}/comments/{id}/replies` does. (#439, #443 by @oliverstreetcreative)
- **Replies posted through a share link now notify the author of the comment they answer.** A member replying to their own comment and replies to guest-authored comments still create no in-app notification. (#447, #450 by @sb123sb123)
- **Comment replies only notify the parent author while they still have access to the asset.** Both signed-in and share-link reply paths apply the same access check, preventing stale threads from exposing asset names and reply previews after access is revoked. (#454, #456 by @sb123sb123)
- **Share-grid comment counts now match what a share visitor can read.** Internal comments and replies beneath hidden comments no longer inflate the count. (#446, #449 by @sb123sb123)
- **EDL exports start at the source's own timecode.** With no `start_tc` given, the EDL export now reads the timecode embedded in the uploaded master (the video stream's, then the container's) instead of always starting at `01:00:00:00`, so record timecodes line up with the source in an NLE. At 29.97 and 59.94 fps a probed timecode's separator picks drop or non-drop frame (`;` or `:`). With no usable embedded timecode the export falls back to `01:00:00:00`, and an explicit `start_tc` is used as given, both in the rate's default mode as before. FCPXML, Premiere XML and CSV exports are unchanged. (#440, #441 by @sb123sb123)
- **Deleting a folder keeps the trash state of what was already in it.** Items trashed before the folder keep their own deletion time and retention clock, and restoring the folder brings back only what that delete trashed, including uploads that were still in flight. (#394, #418, #438 by @sb123sb123)
- **Pressing the picture to close a video-player menu no longer toggles playback.** Dismissing the compact overflow menu or the time-format dropdown from the picture now only closes the menu, and a right-click or a drag off the picture no longer swallows the next click. (#424, #437 by @sb123sb123)
### Contributors
Thank you to @sb123sb123 and @oliverstreetcreative for the pull requests in this release, to @oliverstreetcreative for reporting #439 and #440, to @Lennart-Pingpong for reporting #394, and to @ArnaudGct for the 4K request in discussion #451.
## [1.15.2] - 2026-10-04
Security release for the public share-link endpoints: [GHSA-wgpf-5p75-cxrm](https://github.com/Techiebutler/freeframe/security/advisories/GHSA-wgpf-5p75-cxrm) and [GHSA-5cmh-v8wr-w32g](https://github.com/Techiebutler/freeframe/security/advisories/GHSA-5cmh-v8wr-w32g). Upgrade if you use share links.
### Upgrade notes
- **No migrations, and no new settings.**
- **Three share-link requests that used to succeed are now refused.** The web app does not send them in normal use. `POST /share/{token}/comment` answers 400 for a `version_id` that is not a live version of the shared asset, and 403 for `visibility: "internal"` unless the caller is signed in and can access the asset. `GET /share/{token}/assets` on a link created for selected items answers 403 for a `folder_id` outside the shared folders.
### Fixed
- **A share-link comment now stays on the asset the link points at.** `POST /share/{token}/comment` accepted any `version_id` without checking that it belonged to the shared asset, so a comment could be filed against a version of a different asset and show up in that asset's comment export. A supplied `version_id` must now be a live version of the shared asset, or the request gets a 400, the same rule `POST /assets/{id}/comments` already applied. The export also filters on the asset, so a row written before this fix is no longer included. (#444)
- **An `@mention` in a share-link comment now only notifies people who can see the asset.** The 1.7.4 access check on mentions covered signed-in comments but not the share-link path, which created a mention and an in-app notification for any registered email. Share-link mentions now apply the same check. They still send no email. (#444)
- **An internal comment posted from a share link now stays internal.** `POST /share/{token}/comment` had no `visibility` field, so a signed-in team member who chose Internal on a share page got a 201 and a comment stored as public, which the share link then showed to everyone holding it. The endpoint now takes `visibility`, stores it, and refuses `internal` with a 403 unless the caller is signed in and can access the asset. Share pages no longer offer the Public / Internal toggle, since the share link never shows an internal comment back. (#444, reported by @oliverstreetcreative)
- **A share link for selected items now only lists the folders it shares.** `GET /share/{token}/assets` with a `folder_id` checked only that the folder was in the link's project, so a link created for a few items could list the contents of any folder in that project: asset names, thumbnails, sizes, durations, creators and subfolders. The assets themselves could not be opened. The folder must now be one of the shared folders or inside one, or the request gets a 403, the rule the stream endpoint already applied. Folder links and whole-project links are unchanged. (#444)
### Contributors
Thank you to @oliverstreetcreative for the report behind the internal-comment fix, and for #439, whose triage turned up the rest.
## [1.15.1] - 2026-10-03
### Upgrade notes
- **No migrations, and no new settings.** If you run v1.15.0, upgrade: its stylesheet leaves every hover-revealed control invisible (see the first fix below). v1.14.1 is not affected.
### Fixed
- **Hover-revealed controls appear again, including a comment's resolve check.** The dependency bump in #414 pinned `postcss-selector-parser@6.1.2` to 6.1.3, a broken release that its maintainers replaced with 6.1.4 a few hours later. With 6.1.3 installed, Tailwind emitted no `group-*` or `peer-*` rule at all: the production stylesheet had zero `group-hover` selectors, against 17 with the fix. Every control that is `opacity-0` until its group is hovered stayed invisible. That covers a comment's resolve check and its "⋯" menu, the grid cards' menus and the scrubber thumb. Nothing failed while it was broken: the build, the type check and every component test passed. The override now points at 6.1.4, and a new test compiles `group-hover`, a named group, `group-hover` under an arbitrary media variant and `peer-hover` through the app's own Tailwind config, so a dependency that drops them again fails CI instead. (#435 by @CamReport)
- **Head-trimmed MP4/MOV uploads no longer restore cut footage in stream-copy HLS renditions.** This was the known issue listed for the opt-in `TRANSCODER_SOURCE_COPY` in 1.15.0. Sources with discard-flagged packets or a negative first-packet PTS now use the encode path. A trim at the end of a file, or an edit list in the middle of one, can still fall outside the stretches the copy gate samples and be copied as before. (#431, #436 by @sb123sb123)
### Contributors
Thank you to @CamReport and @sb123sb123 for the pull requests in this release.
## [1.15.0] - 2026-10-01
### Upgrade notes
- **No migrations, and no new settings you must set.** An upgrade that touches no configuration behaves as before, apart from the two transcoding changes below.
- **`NEXT_PUBLIC_BASE_PATH` is read at build time.** It is empty by default, so root deployments are unchanged. To serve FreeFrame under a path, set it and rebuild the web image; changing it on a running container does nothing.
- **pytest is no longer in the production API image.** Test-only dependencies moved to `apps/api/requirements-dev.txt`. If you run the test suite inside the production container, install that file first.
- **A transcode that comes out short of its source is now retried.** The task's existing retries (three, a minute apart) read the source again, so a short read costs extra encodes and shows up in the worker log. On the last attempt the result is stored anyway with a log line, so a version never ends `failed` because of this check.
- **An audio-only `.mp4` with embedded cover art is now stored as an audio asset.** It used to become a one-frame "video" of the cover.
- **Known issue with the opt-in `TRANSCODER_SOURCE_COPY`:** an MP4 trimmed with an edit list (for example an `ffmpeg -ss ... -c copy` cut) is remuxed with the footage that was trimmed off (#431). The setting is off by default; leave it off if your uploads include such cuts until #431 is fixed.
### Added
- **Sub-path deployments.** `NEXT_PUBLIC_BASE_PATH` is now wired into `next.config.js`, so an instance can be mounted under a path (e.g. `https://example.com/freeframe/`) instead of the domain root. Pages, links, static assets, icons and copy-to-clipboard URLs all live under the prefix; the API stays at the origin root, and CORS matches the bare origin so a path-bearing `FRONTEND_URL` still works. Empty by default, so root deployments are unchanged. Build-time variable: rebuild the web image after changing it. (#281, #407 by @rubenxyz)
### Changed
- **The API test tooling moves to pytest 9, and out of the runtime image** — pytest 8.4.2 was pinned in
`apps/api/requirements.txt`, hits the CVE-2025-71176 advisory, and because the Dockerfile installs that
file it was shipping the test runner in the production image. Test-only deps now live in a new
`apps/api/requirements-dev.txt` (`-r requirements.txt` + pytest 9.0.3 + pytest-asyncio 1.4.0) that CI and
local dev install; the image installs the runtime file alone. pytest-asyncio comes along because 0.26.0
pins `pytest<9`; the suite drives asyncio via `asyncio.run()` in sync tests, so the 0.x → 1.x jump is
dependencies-only here. The full backend suite (475 tests, `python -m pytest apps/api/tests/`) stays green
under the new pair.
- **The frontend's dev tooling is on newer, non-vulnerable versions** — Vite moved from 8.0.1 to 8.3.0 and is
now declared as a direct devDependency (it was only ever reaching the tree transitively), Vitest to 4.1.11,
and the build-time transitives Dependabot flags were bumped: `nanoid@3` to 3.3.18, `glob@10` to 10.5.0,
js-yaml to 4.3.2, `brace-expansion@1` to 1.1.18 and `postcss-selector-parser@6.1.2` to 6.1.3. PostCSS is
keyed to the one vulnerable copy, `postcss@8.4.31`, which is what next@14.2.35 pins exactly; an unkeyed
entry collides with the direct devDependency and npm refuses the whole manifest with `EOVERRIDE`, which
matters because the Docker Compose dev stack installs with npm. The pins are declared both npm- and
pnpm-style so they hold whichever install path is used. `apps/web` now declares its Node engine
(`^20.19 || ^22.12 || >24`) and the contributing guide's prerequisite is corrected to match. Everything
here is dev/build-only, with no runtime behaviour, and the full web gate stays green: type check,
638 tests, production build, lint. (#366, #414 by @Thedude7054)
### Fixed
- **A transcode that comes out short of its source no longer passes unnoticed** — ffmpeg's exit code was the only thing that decided whether a ladder was good, and it does not answer this question. If the input stops being readable part-way through, the demuxer reaches what looks like the end of the file: the muxer closes the playlist with `#EXT-X-ENDLIST`, ffmpeg exits 0, and what reaches the bucket is a complete-looking HLS ladder holding a fraction of the video. On a live instance a 31:18 master was stored this way as 1:37, `ready` in the database beside the source's real duration, and it took someone dragging the scrubber to the end to find it two days later. Reproduced on that master by truncating it at three successive frame boundaries: exit 0 every time, with 92.4s, 96.1s and 99.8s of output and a well-formed playlist each time. Cutting inside a video packet is no louder — the encode pipeline decodes through the damage and still exits 0, and a dropped HTTP read logs `Input/output error` before exiting 0 as well; only the remux reports it, and there the existing fallback to the encoder absorbs it. The ladder's own playlists are now read back and compared against the source's **video track**, and a result more than 3 seconds short fails the transcode so that the task's existing retry reads the source again. The video track and not the file: `#EXTINF` measures picture, while a container's duration is its longest stream, so a rough cut whose audio runs past its last frame — ordinary out of an NLE — would otherwise be refused while being perfectly intact. Three seconds is a flat allowance rather than a share of the duration because the drift it covers is flat: measured at +0.023s on the copy path and −0.040s on the encode path alike, on 1-, 10- and 40-minute sources. **Two things bound how far this can go wrong,** because a check that refuses an intact upload is worse than no check: such a refusal reproduces on every read, and a version that ends `failed` has its master deleted by the stale-upload reaper a day later. First, the comparison runs only where the file genuinely states how long its picture is — the per-stream duration of the mov/mp4 family (less the start offset in a fragmented file, where that duration is an end timestamp), and Matroska's exact `DURATION` tag, capped at the container's duration and with the stream's start offset subtracted, since that tag is an end timestamp rather than a length. Everywhere else it declines and the transcode proceeds unjudged, because the number those demuxers publish is synthesised rather than measured: ASF copies the file's play duration onto every stream, AVI derives it from a frame count that includes the zero-size chunks a held last frame is stored as, MPEG-PS reports a PTS span that a clock jump inflates, and a cover image sitting at `v:0` is handed the whole file's length. Second, the last attempt keeps the ladder it built and logs the mismatch with both numbers instead of failing, so a refusal costs a re-read rather than the master. Nothing changes for a transcode that runs to the end. (#415, #416 by @Lennart-Pingpong)
- **MP4 attached pictures no longer replace the video during transcoding.** The video probes, filter graph, stream-copy path and metadata backfill now select the first video stream not flagged as an attached picture; an audio-only MP4 with attached art stays on the audio path. (#430, #432 by @sb123sb123)
- **The optional MinIO bucket-init service in `docker-compose.prod.yml` now creates the bucket.** The
commented-out `minio-init` block never loaded `.env.prod`, so `S3_BUCKET` and the MinIO keys were empty
inside the container and it failed with `Bucket name cannot be empty`, while a trailing `exit 0` hid the
failure. It now loads `.env.prod` and exits non-zero if the bucket can't be created. The deployment guide
also notes that bucket names can't contain underscores, and no longer claims the production compose has no
MinIO option. (#426 reported by @moorsey, #427)
- **The compact transport row's fullscreen button is a 44px target, and quality moved into the overflow menu** — #346 gave the play button and the overflow-menu rows the platform's 44px minimum on a phone, but left the fullscreen button at 28px and the quality `<select>` inline, at 69x28 with `text-xs` below the 16px size iOS Safari zooms on. Growing fullscreen alone would have pushed the row 15px past a 360px screen whenever an HLS ladder is loaded, which on a phone is nearly always, and the share screen's `overflow-hidden` would have clipped the button against the edge. Below `sm`, fullscreen is now `h-11 w-11` and quality is a row in the "More controls" menu that swaps the menu's rows for Auto and each rung, as 44px rows. Swapping rather than appending keeps the menu at the four rows that fit above the transport row on a portrait phone. Above `sm` both are unchanged. (#420 by @CamReport)
- **A share link's password gate and folder search no longer zoom the page on iOS Safari** — the two share-link inputs #324 did not reach. Safari zooms whenever focus lands on a text input under 16px, so the password gate's input (which is `autoFocus`) zoomed the page when it took focus, and the folder browser's search box zoomed on tap. Both are now 16px on a touch device and keep their original `text-sm` under `(hover: hover)`, the same guard as the review inputs. (#421 by @CamReport)
- **First tap on iOS only buffered, a second tap was needed to start playback** — `video.play()` can reject while the element is not ready yet (ManagedMediaSource), and the rejection was silently swallowed. `play()` is now retried once on the next `canplay` event, and `touch-action: manipulation` on the player removes the 300ms double-tap delay. The retry is disarmed on a source or version change, on unmount and on the player's own deliberate pauses (the play toggle, a comment timecode, the comment box), so it no longer starts playback nobody asked for on those paths. (#404 by @jeremy-pixelated)
- **The S3 orphan sweeper now covers the five prefixes it was never told about** — it swept `raw/` and `processed/` only, while the app also writes project posters, user avatars, comment attachments, branding logos and watermark output. An object under any of those whose owning row was deleted was paid for forever, with nothing that would ever reclaim it. Each new prefix gets the liveness rule its own owning column gives it, and every one of those queries is unfiltered on purpose, because a soft-deleted row still owns its object until the retention GC comes for it. One of them was a trap worth naming: `User.avatar_url` is called a URL and holds an S3 key, so trusting the name would have swept every avatar on the instance. `watermarked/` is included as pure garbage, which is what it is: `apply_watermark` writes there and nothing in the codebase ever reads it back (#247). `branding/<project>/watermark/` is deliberately excluded and stays unswept, because the endpoint that uploads those images returns a key nothing stores and `WatermarkContent` has no image variant, so no row can vouch for them and the sweeper cannot tell an abandoned one from one an instance is using through the API directly. This is the widening the previous release's ratio floor was put in first to make safe: a liveness rule that is wrong or missing now shows up as a refused sweep and a loud log rather than as deleted data. The sweeper is still off unless `ORPHAN_SWEEP_GRACE_HOURS` is set, and report-only unless `ORPHAN_SWEEP_DELETE` is too. (#115, #247)
### Contributors
Thank you to @Lennart-Pingpong, @CamReport, @rubenxyz, @jeremy-pixelated and @Thedude7054 for the pull requests in this release, to @sb123sb123 for a first contribution, and to @moorsey, whose issue (#426) led to the MinIO init fix.
## [1.14.1] - 2026-09-21
### Fixed
- **`TRANSCODER_SOURCE_COPY` no longer copies a master it has not really looked at** — the gate that decides whether an upload can be packaged without re-encoding judged the first 30 seconds and generalised, counted one keyframe more than the file has, and never measured the stretch after the last keyframe it found. Each of the three let through a master that copies "successfully" and produces an asset a reviewer cannot seek in, which is the one thing the gate exists to prevent. Reproduced with real files: a slate spliced onto a long-GOP body copied into a single 41-second segment, and a lossless join of two clips copied into 27 segments of which the last nine decode to `non-existing PPS 0 referenced` while ffmpeg exits 0 and the job reports success. The gate now samples the tail of a long master as well as its head, counts only the keyframe lines ffmpeg's bitstream tracer actually emitted rather than including the one its own decoder logs first, measures the gap running off the end of a window, and refuses outright if the trace it depends on parsing says nothing at all. Two windows is a sample and not a proof, and the docstring says so: a three-part join whose middle piece is the odd one still gets through, and only a full pass over the master would catch it. The setting stays off by default. (#377, #378, #379, #381)
## [1.14.0] - 2026-09-21
### Upgrade notes
- **Two migrations.** `asset_versions.chunk_size_bytes` records the part size an upload was started with, so a resume places its parts on the boundaries the parts already in the bucket were cut on. Nullable, no backfill: rows created before it derive the size from the parts storage is holding. The second, `media_files.s3_key_download` (revision `e3a7c9d4b210`), records the single-file MP4 a download falls back to. Both are nullable `ADD COLUMN` with no backfill and no table rewrite; versions transcoded before them fall back to the behaviour they had.
- **New endpoint: `GET /upload/{version_id}/parts`.** Reports what an interrupted upload already holds, so a client sends only the rest, and `last_activity_at`, when the upload last moved before that request. Answers 409 for a version that is no longer uploading. Asking does not count as activity while the upload moved within the last five minutes.
- **Versions in asset responses carry `last_activity_at`**, when the upload last moved. Additive; `null` on rows older than the column.
- **`POST /upload/abort` takes an optional `discard`** (default `false`, unchanged behaviour). Set, and only for a version still uploading, the parts and the version are deleted rather than the version being marked failed, and the asset too when that was its only version and the caller is an editor on the project. The web client sends it for Discard and for Cancel.
- **The project trash leaves out assets that never had a version reach processing**, and `POST /assets/{asset_id}/restore` answers 404 for one. These are uploads that were discarded or reclaimed before they landed, and they are soft-deleted so they stop showing in the grid as a card nothing can open. An asset whose transcode FAILED is not one of these: it landed, it is deleted work, and it stays in the trash and restorable.
- **New video uploads use more storage, with no setting involved.** Two fixes in this release each grow what a new version stores: renditions now carry the bitrate they were always configured for (about 2.2x the HLS ladder on our measurement) and every new video version also keeps a single-file download MP4 about the size of its top rung. Together that is roughly 4x the processed storage per new video version on the same source. Nothing re-encodes, so existing versions do not grow and the increase arrives only with new uploads. The per-rung quality values are not configurable; trimming the ladder with `TRANSCODER_QUALITIES` is the lever if you need the smaller outputs back.
- **Four new settings, all off or unchanged by default**, so an upgrade that touches no configuration behaves as before: `TRANSCODER_CPU_LIMIT` (empty = unbounded, as today), `TRANSCODER_SOURCE_COPY` (`false`), `ORPHAN_SWEEP_MAX_ORPHAN_RATIO` (`0.5`, and the orphan sweeper itself stays off unless `ORPHAN_SWEEP_GRACE_HOURS` is set), and `API_INTERNAL_URL` (optional, for server-side branding fetches).
### Added
- **A source that is already browser-safe can be packaged without re-encoding** — `TRANSCODER_SOURCE_COPY=true` (default off, so nothing changes for anyone who does not set it) lets a deployment skip the encoder when the ladder has already resolved to a single rendition at the source's own size and the upload is H.264 8-bit 4:2:0 — which a master exported from an NLE usually is. It is then remuxed into HLS with `-c:v copy`: the same frames the editor exported, cut into segments at the keyframes they already have. Measured on a 36-minute 1080p master on six CPU cores, 355s against about 55 minutes, and the result is the master's own picture rather than a slightly worse copy of it. Everything else is encoded exactly as before: a ladder with more than one rung, a rung below the source resolution, 10-bit or 4:2:2 H.264 (which report the same codec name and play in no browser, so the pixel format is checked and not just the codec), any other codec, and HDR or Dolby Vision, whose tone-mapping is the reason the filter graph exists. Two more are excluded because a remux cannot carry what an encode bakes in: a source with a display rotation, which lives in the container and would leave a portrait clip lying on its side next to an upright thumbnail; and a master whose random-access points are not IDRs, or are more than 12s apart. A copy keeps the source's own keyframes, and those are the points a player seeks to — an open-GOP master copied as-is plays from the top and reports success, and then fails on every click of a comment's timecode. Both are established by a probe of the first 30s before anything is copied, and a remux that fails anyway is encoded rather than failed. AAC audio is copied along with the video, anything else (PCM is common out of an NLE) is encoded to AAC, which costs seconds and leaves the picture untouched. A copied version carries no separate download MP4: its rendition is the uploaded stream, so the master a download falls back to is the same picture at the same bitrate, and a second copy would spend the storage this setting was turned on to save. (#333, #372 by @Lennart-Pingpong)
- **A deployment can cap how much of the machine one transcode takes** — `TRANSCODER_CPU_LIMIT` accepts a core count (`6`) or a share of what the process may use (`50%`); left empty, nothing changes. Unbounded, ffmpeg picks its own thread counts, which on a 16-core host works out at about 10.6 cores for the default ladder — and still 9.5 for a single rung, so #325 was no way to get the machine back either. For a self-host that is the whole box: the database, the file server and everything else slow down for the length of every upload, and there was nothing to reach for short of editing the deployment. The budget is split across the rungs as a per-stream `-threads`, and the filter graph gets half of it; measured on 20s of 1080p30, three rungs at two threads each occupy 4.9 cores against 10.6, and total CPU time is unchanged (66.5s against 66.9s) — a cap buys wall-clock time rather than wasting work, which is what makes it safe to hand to an operator. The graph's share is not cosmetic: held at one thread it becomes the bottleneck on anything with real work to do, and a 4K HDR upload granted six cores took 1.65 of them and 101.5s, against 3.68 and 51.1s at half the budget. The whole budget is faster again but overran the cap on both 4K sources tried, so half is the largest share tried that stayed inside it on all three; on a 1080p SDR source, where a graph has almost nothing to do, the share makes no measurable difference either way. A share is read against a cgroup quota when one is set rather than the host's core count, so `50%` on a worker already capped at 4 of 16 means 2 and not 8. Every rung keeps at least one thread, so a budget below the rung count is honoured as closely as it can be and the shortfall is logged rather than rounded down to zero — ffmpeg reads a zero thread count as "pick for yourself" and accepts it, so rounding there would not fail the command, it would silently switch the cap off. An unusable value falls back to unbounded with a line in the log, since this setting gets written when a machine is already struggling and a typo must not cost every upload. The encoder threads are for software encoding only — the hardware backends take their thread counts from the driver — but the filter graph is capped on every backend, which on a hardware one is all this bounds: worth having for an HDR master, whose tone-map runs on the CPU, and worth little for an SDR one, which scales on the device and leaves the graph nothing to do. What no backend caps is decoding: the option that would do it sits before `-i`, where it reaches the decoder and leaves every encoder unbounded, so it is deliberately not used — and on the full job it moves occupancy by less than the run-to-run spread anyway, not because the decoder is cheap but because that option is a loose bound on a codec threading on frame and slice level (`-threads 2` measured 4.61 cores, not 2). So the honest reading of the setting is "roughly this, plus whatever the decoder costs". How much that is depends on the master and on how long the rest of the job runs, since an uncapped decoder runs only as fast as the capped stages take frames from it: measured on 16 cores at a budget of 6, the same 12.5s of 1080p, an H.264 master decodes in 5.8 CPU-seconds and stays inside the cap at 4.45 cores for three rungs and 5.92 for one, while a ProRes 4444 XQ master decodes in 24.0 and takes 5.47 for three rungs but 7.70 for one — 28% over. The same three-rung configuration measured 7.46 on an 11-core host with a real 1.8GB NLE export, so a heavy master at the default ladder is anywhere from inside the cap to about a quarter over, depending on the box. The shorter the ladder and the faster the machine, the less the number holds; it is still worth setting, 7.70 against 12.74 for that master uncapped at three rungs, but it is a budget for the encoders and the graph rather than for the process. One thing a low budget does not buy back: an ffmpeg run is still killed after 4 hours and the version fails rather than falling back, and three rungs at one thread each run at 0.68x realtime, so a master of roughly three hours needs headroom above the smallest budgets. (#375, #390 by @Lennart-Pingpong)
- **Files can be dropped onto the asset area to upload them** — the drop target existed but only inside the "Upload asset" dialog, so it could not be reached until after the click it was meant to save, and dropping a file on the page itself made the browser navigate away to display the video. The whole content region now accepts a drop, including the empty state that says "Upload your first asset to get started" and the blank space below a short row of cards. Dropping onto a folder uploads into that folder, and while a folder is the target it is the only thing marked, so it is always visible where the file will land. That holds for the folders in the sidebar tree as well, and for the project row above them, which uploads into the project root. Anywhere in the dashboard that does not take a file now refuses it explicitly, since a file the browser is left to handle navigates the tab away from the app to display it — the two pixels of gap between two folder rows were enough to lose the page that way, and so was the attribution badge floating over the bottom-right of the asset area. The refusal sits on the app shell rather than on the page, so it also covers the header, the sidebar rail and any dialog, none of which are inside the page element. Dragging an asset between folders is unaffected: an upload only starts for a drag that carries files. (#315, #327 by @Lennart-Pingpong)
- **Comments are a bottom sheet on a portrait phone, so the video stays in view** — below `md` the comment panel was a full-width overlay that covered the player entirely, so reading or writing a comment meant losing the thing being reviewed. It is now a sheet pinned to the bottom with a 56px peek band that is always visible, draggable between peek, half and full, with a mini transport in the handle row once it covers the player's own controls. The media re-centres in the space left rather than hiding behind the sheet. A landscape phone keeps the side-by-side column from #338, where the spare room is horizontal rather than vertical. (#341) The share screen a guest opens gets the same treatment. Opening the sheet scales the media to fit above it rather than sliding it out of view, so the whole frame stays visible at every height, and turning on drawing collapses the sheet to just its composer, since nobody marking up a frame is reading older comments.
- **An asset can be renamed from the uploads panel, while it is still uploading** — the name was decided in the "Upload asset" dialog before a byte moved, which is the one moment the file is not on screen next to it, and a file dropped straight onto a folder skips that dialog entirely and is named after whatever the camera or the NLE called it. A row in the uploads panel now takes a click on the name, or on a pencil beside it, and edits in place. `asset_id` exists as soon as `/upload/initiate` answers, long before the transfer ends, and `/upload/complete` does not write the name again, so a rename at 40% survives the upload it is riding on. It is offered from the moment the row appears, including while it still says Queued: initiate is dispatched in the same tick the row is created, so the one window where an edit could be lost is the round trip itself, and a rename made during it is applied to the asset as soon as there is one. One kind of row is deliberately left out — a row rebuilt from upload history, which comes from `/me/assets`. Those rows are the user's own assets, and being an asset's creator is still not proof of the editor role a rename needs: that query carries no membership term, so an asset created before a demotion or a removal comes back all the same and the edit could only answer 403. (#330, #368 by @Lennart-Pingpong)
- **An interrupted upload can be resumed instead of started again** — a transfer that stopped part-way now offers Resume. The server reports which parts the storage backend is already holding, and only the missing ones are sent; a 5 GB file that died at 80% costs the remaining 20%, not another 5 GB. This is the point of no longer aborting on failure: the parts have to still be there for anything to resume from. Because a browser cannot reopen a local file by itself, resuming asks for the same file again — that is inherent to every browser-based resumable uploader — and the name and size are checked against the upload before anything is sent. An upload whose object turned out to be assembled already needs no file and completes on the spot. Discard is the other new button, and is now the only path that throws parts away. (#241, #312 by @Lennart-Pingpong)
### Changed
- **The video transport row collapses on a phone** — below `sm` it ran out of width and the controls crowded together at a 28px target. Loop, speed and mute move behind an overflow menu, the remaining controls get the platform's 44px minimum, and the row carries the home-indicator inset. Above `sm`, including a landscape phone, everything stays inline exactly as before. (#341)
### Fixed
- **Every ladder rung except the smallest was encoded at the smallest rung's quality** — the encoder is selected per stream with `-c:v:{i}`, but `-crf` was emitted without a stream specifier, and ffmpeg applies the last unqualified copy to every video stream. The ladder builds largest rung first, so the 1080p, 720p and 360p renditions were all encoded at the 360p rung's CRF. Measured on a 6s 1080p source with the exact command this builds, the 1080p rendition came out at 1776 KB where the configured quality gives 4264 KB, and 720p at 468 KB against 812 KB; the 360p rendition is byte-identical either way, because it is the one whose value survived. The same defect was in the QSV and VAAPI branches, which spell the rung's quality `-global_quality`. `-pix_fmt`, `-qp` and `-force_key_frames` are qualified along with them: their values happened to be identical on every rung so they changed nothing, but they are emitted the same way and two of them were the only reason ffmpeg warned about any of this. **This makes new uploads larger.** The renditions now carry the bitrate they were always configured for, which on the measurement above is about 2.2x the total for a video; nothing re-encodes, so existing versions are untouched and the growth arrives only with new uploads. The per-rung CRF values are not configurable, so an instance that needs the smaller outputs back has to trim the ladder with `TRANSCODER_QUALITIES` instead. (#393)
- **The uploads panel's history no longer lists other people's files** — the panel rebuilds recent uploads from `GET /me/assets`, and asked for it with no filter. Unfiltered, that endpoint answers with every asset in every project the user is a member of, in whatever role, plus anything merely shared with or assigned to them — so "your uploads" was in fact "everything you can reach". A reviewer who has never uploaded anything opened the panel to a list of other people's work, and on an instance with a few shared projects their own files are a fraction of what the panel shows. The filter this needs already exists: `filter=owned` resolves to `created_by = <this user>`. Both pages now ask for it, the first and every "load more", since fixing only the first would have let the second page put the other assets back. That is the asset's creator rather than each upload's, which is the one place the new list is still not exactly "your uploads": a new version uploaded onto a colleague's asset is the user's upload, leaves `created_by` with whoever made the asset, and so is no longer listed. The narrower filter that would catch it is an API change and is left for its own. The rows themselves, the merge with uploads still in flight and the paging are untouched; a shorter list only ends the paging sooner. (#374, #397 by @Lennart-Pingpong)
- **Every entrance and exit animation in the app was inert** — `animate-in`, `fade-out-0`, `zoom-in-95` and the rest of that vocabulary are used on 194 class slots across 27 files, 183 of them gated on a Radix `data-[state=...]`, but `tailwindcss-animate` was never installed and `plugins` was empty. Tailwind only emits classes it knows about, so all of them were dropped: dialogs, the command palette, popovers and dropdowns appeared and vanished instantly. The plugin is installed and registered, and the classes now reach the stylesheet. Installing it alone would have made things worse rather than better, which is the part worth knowing: the plugin animates through a keyframe that sets `transform` outright, and that replaces the `-translate-x-1/2 -translate-y-1/2` a centred dialog uses to position itself. Measured in a browser against the built stylesheet, a centred dialog's first frame sat 192px right and 105px below where it belongs and then snapped into place. The eighteen surfaces that centre themselves this way now pin the animation's own translate to the same offset, which puts that first frame back on centre, 9px low, which is the rise the animation was always meant to have. (#365)
- **The uploads panel no longer covers an expanded sidebar** — the panel opened at a fixed 52px from the left, which is the collapsed rail's width. With the sidebar expanded to 220px it opened on top of it and cut every label back to its first letter, undoing the one thing expanding the sidebar is for. It now opens against whichever edge the sidebar has, on a tablet and up; on a phone it stays where a collapsed rail puts it, because 220px and the panel’s own 380px do not fit together. (#369 by @Lennart-Pingpong)
- **The S3 orphan sweeper refused to delete only when the database was completely empty** — it reclassifies any key no `MediaFile` row owns as garbage, so a database that does not describe the bucket it is pointed at turns the whole bucket into garbage. The guard against that checked for zero rows, which is only the most extreme form of the accident: one surviving row against a full bucket is the same mistake and sailed straight through. It now refuses when more than `ORPHAN_SWEEP_MAX_ORPHAN_RATIO` of the keys it scanned look orphaned (default half), and says which of the two reasons stopped it. Raise the setting deliberately, after reading the report, if the bucket really is that dirty. Separately, what it reported reclaiming counted attempts rather than successes, and the byte figure totalled every orphan it had found, so a sweep whose deletes S3 refused still reported the storage as freed. Both now follow the deletes that actually happened. The sweeper is off unless `ORPHAN_SWEEP_GRACE_HOURS` is set, and report-only unless `ORPHAN_SWEEP_DELETE` is too, so no running deployment was deleting anything. (#115)
- **"Powered by FreeFrame" no longer sits on top of the asset navigator** — the asset viewer's top bar centred the credit with absolute positioning, and the "‹ 5 of 7 ›" navigator is centred there too, so on any asset with a neighbour the two were drawn over each other. The credit was also winning the hit test, so `elementFromPoint` at the centre of the "next asset" chevron returned the credit's anchor and the chevron could not be clicked at all. They now share the centre, navigator first, which means the navigator itself sits a little left of the bar's centre line on an asset with a neighbour. An asset with no neighbour shows the credit alone, as before. Below the `lg` breakpoint the credit is not rendered in the bar: in flow it is 147px that neither the middle group nor the controls on the right can give back, so the surplus left the bar past its right edge, where the page root clips it and nothing scrolls to it -- taking the sidebar toggle with it on an iPad in portrait or two windows side by side. (#370 by @Lennart-Pingpong)
- **The identity menu in a shared folder browser opens on a touchscreen** — the "Reviewing as …" control revealed its menu with `group-hover:block` and carried no click handler at all, so on a phone or tablet there was no gesture that opened it: a guest could not see which identity their comments were being filed under, and could not change it. It is a real dropdown now, the same one the asset cards use, so it opens on a tap and closes on Escape or a tap outside. (#349, #362 by @CamReport)
- **A file dropped on a share link no longer navigates the guest away** — #327 taught the dashboard to refuse a file the browser would otherwise open in place of the app, but the share tree a guest sees is a separate surface and was left uncovered, so dropping a cut anywhere on a review page replaced it with the video in a bare browser tab. A guest has no session to come back to, so it ended the review rather than interrupting it. The share tree now refuses a dropped file the same way the dashboard does. (#351, #363 by @CamReport)
- **The compare view's scrubber works on a touchscreen** — the seek bar under a version comparison wired up mouse events only, so a tap seeked once by accident, there was no drag at all, and the target to aim at was the 4px visual track. It now runs on Pointer Events the way the main transport has since #323: the drag survives the finger leaving the track, ends cleanly when an iOS gesture takes over or capture is lost, ignores a second pointer and a non-primary button, and the widened hit area is no longer swallowed by the comment-markers row. (#353, #364 by @CamReport)
- **A white-labelled instance no longer shows FreeFrame's brand first** — branding was fetched only from the browser, after mount, so every first paint used the built-in defaults: the FreeFrame wordmark, the name "FreeFrame" under it, `<title>FreeFrame</title>` in the tab and the product's own accent colour. The instance's real brand replaced all of it once the request came back. On a deployment far from the viewer that gap is long enough to read, so a client opening a share link saw someone else's product before the one they were invited to. The server now reads branding and renders it into the HTML, so the name, the logo, the tab title, the favicon and the accent are right in the first byte and nothing swaps. The sign-in screen was a second copy of the same problem, with its own hardcoded `FreeFrame — Auth` title overriding the instance name entirely; it is resolved on the server too. Because the page is served with the answer, the browser no longer makes the branding request at all, which removes a round trip from every page load. An instance that has not customised anything is unaffected, and a deployment whose web server cannot reach the API falls back to fetching from the browser exactly as before. Self-hosters behind a reverse proxy should set `API_INTERNAL_URL` (see `.env.example`); compose sets it already.
- **"Download" on a video no longer hands over the camera master** — the transcode produced HLS and nothing else, and a playlist with a few hundred segments is not something anyone can be given as a file, so the download path fell back to the raw upload: the editor's master, which is often an order of magnitude larger than the rendition the reviewer actually watched and, on a slow link, an unkind default. Each version now also carries one MP4, remuxed from the top rung of the ladder it has just encoded with `-c copy` — no decode and no second encode, the same bytes that were streamed — with its index moved to the front of the file so it starts playing while it is still arriving. Both download paths prefer it, the signed-in one and a share link's. Nothing is backfilled and no transcode is re-run, so every version that predates this keeps handing out the master exactly as before, and a remux that fails leaves a working asset rather than a failed one: the ladder is what a video is uploaded for. This adds one object per new video version, about the size of that top rung, and a nullable `media_files.s3_key_download` column. (#350)
- **A download's filename now describes the bytes it carries** — audio is served as the MP3 the transcode produced and a still as the WebP, but the name was always built from the upload's own filename, so a master uploaded as `Mix.wav` downloaded as `Mix.wav` containing MP3 bytes, and `Poster.png` contained WebP. Most image tools and several players refuse a file whose extension contradicts its contents rather than looking inside it. Where the asset had since been renamed the name could disagree with both: an asset on our own instance named `…png`, uploaded as a `.jpg` and served as WebP, downloaded as `…png.jpg`. The extension now comes from the object actually being handed out, and the stem stays the name the reviewer recognises — `Mix.mp3`, not `audio.mp3` or `Mix.wav.mp3`. An extension the display name inherited from a file is replaced rather than stacked on, and anything that is not a media extension is left alone, since an asset name is free text and "Scene 2.5" is not a name plus an extension. Both paths that hand out a download are covered, the signed-in one and a share link's. (#350)
- **The comment composer's Send button can be clicked again** — the attribution badge floats `fixed` in the bottom-right corner of the whole dashboard, and the asset review page puts the composer's send button in that same corner. At 173px wide against the button's 28px it covered it completely, and being an `<a href>` it won the hit test outright: `elementsFromPoint` at the button's own centre returned the badge above it, so every click on Send opened the repository in a new tab and the comment was never posted. Pressing Enter still posted it, which is why this went unnoticed since the floating badge was added. The review page already renders its own credit in the top bar it draws in place of the header, so the floating one was a second copy on that route and is no longer rendered there. Every other page keeps it exactly as before, and both copies read the same branding setting, so turning the credit off still turns it off everywhere. (#356)
- **A reply can no longer be filed against a comment on another asset** — `POST /assets/{id}/comments` validated that `version_id` belonged to the asset and then wrote `parent_id` through with no check at all. Because the single-comment response builder walked a thread by `parent_id` alone, a comment filed against an asset the author could reach was rendered inside a thread on an asset they could not, for whoever next edited, resolved or replied to that comment. The asset list endpoint builds its tree through a different helper that does filter by asset, which is why this never showed up in normal use. Both halves are closed: the parent must now be a live comment on the same asset, and the response builder filters by asset too, so a row an earlier build already committed is not rendered either. The milder shape of the same gap is also gone — a reply to a soft-deleted parent was accepted with a 201 and then returned by no read path ever again.
- **A timecoded or annotated reply keeps what it was sent with** — `POST /assets/{id}/comments/{id}/replies` accepts a full comment body but built the row from the message text alone, so the timecode, the visibility and the annotation were accepted and silently dropped, with a 201 to say it had worked. A drawing is the part of a review note nobody can reproduce by retyping. All four are persisted now, and `version_id` is still taken from the parent, since a reply belongs to the same version as what it answers.
- **A dropped connection no longer signs you out** — any failed token refresh cleared both tokens and navigated to the login page, and `fetch` rejects on a transport failure, so going through a tunnel or losing wifi for one request ended the session and took whatever was open with it. A proxy returning 502 during a deploy did the same to everyone at once. Only the server refusing the refresh token, with a 401 or 403, ends a session now; anything else leaves it intact and the request simply fails.
- **Uploading one file and uploading several no longer name the assets differently** — picking a single file put the filename without its extension in the name field, while picking several uploaded them under the full filename, so the same set of rushes came out as `A047C012` or `A047C012.mov` depending only on how many were selected at once. Both paths use one helper now, and so does dropping files onto the asset area, which had a third copy of the same rule. A name that is nothing but an extension keeps it, since stripping `.gitignore` leaves an asset called "". (#330, #368 by @Lennart-Pingpong)
- **Edge-pinned surfaces clear the notch and the home indicator on a phone** — the app never set `viewport-fit=cover`, and without it iOS resolves every `env(safe-area-inset-*)` to zero. Padding written against those values looked present in the source and did nothing at runtime, so the comment sheet sat behind Safari's bottom bar. `cover` is set now, together with the padding it makes real: the sheet and transport rows clear the indicator, the uploads panel and notification drawer clear it too, the attribution badge no longer sits under it, and the review roots clear the notch in landscape. The two had to land together, since `cover` alone would push content under the cutouts instead. (#340)
- **A tall video on a portrait phone is no longer pinned to one corner** — #338 sized the player from the media's own ratio and capped it, but a clamped height makes the width definite through the ratio, which switches off the flex stretch that was making the box fill the column. A 608x1080 source came out 302px wide in a 440px column, flush left, with all 138px of letterbox on the right and the leftover height pooled under the transport row. The box is pinned to the column's width now, so `object-contain` centres the picture and splits the bands, and the stack is centred vertically so the slack is shared rather than sitting in one lump. Measured on an iPhone 16 Pro Max: 63/64px bands and 65px above and below, against 0/138 and 0/131 before. (#338)
- **The fullscreen button works on an iPhone** — it called `Element.requestFullscreen`, which does not exist on iOS Safari, so tapping it threw "requestFullscreen is not a function" and did nothing. There is no element fullscreen on that platform at all: an iPhone can only put the video element into the system player. The button now tries the standard element API, then Safari's prefixed one for iPadOS and the desktop, then the video's own native player, and exits through whichever matches. The fullscreen state also tracks the video element's begin and end events, which are the only signal an iPhone emits, so the button no longer stays stuck showing the wrong icon. On an iPhone the system player replaces our chrome while it is up, including the annotation overlay, which is a platform limit rather than a choice.
- **The video review layout is usable on a phone** — in portrait the player was sized by whatever height the column had left rather than by the video, so a 16:9 cut sat in a box roughly twice its height with about 165px of black above and below it and the controls pushed toward the bottom edge. The stage is now sized from the media's own ratio below the `md` breakpoint, read from the loaded video rather than assumed, so a vertical or 4:3 source is right too, and capped so a tall source cannot push the controls off instead. In landscape the layout was pinned to `100vh`, which on iOS Safari is the height with the browser toolbar collapsed, with `overflow-hidden` at every level: part of the transport bar sat under the toolbar and no scroll could reach it, because nothing could scroll and so Safari never got the gesture that would have collapsed the toolbar. Both layout roots now use `dvh`, and on a landscape phone the comment panel sits beside the video rather than over it, which is where the room actually is. The project review page also stopped opening its 360px comment sidebar over a 390px screen. (#331 reported by @Paracid, landscape approach suggested by @CamReport)
- **The video scrubber was unusable on a touchscreen** — the track only wired up mouse events, so a tap happened to seek once but there was no continuous drag, and the draggable target was the 4px visual track itself. Every handler is now Pointer Events (mouse, touch, and pen through one path), with the dragging pointer captured (`setPointerCapture`) so it keeps driving the seek outside the track's own bounds, ends cleanly on `pointercancel` (an iOS gesture taking over mid-drag) and `onLostPointerCapture` (capture lost with no matching release), and ignores a second pointer or non-primary button so a two-handed grip or a right-click can't hijack or permanently latch it. The touch target is widened with an invisible overlay — asymmetric (`-top-1`/`-bottom-3.5`), so it stays inside this component's own padding above (nothing here has room to spare into the video area rendered directly above it) while clearing into the transport bar's own button gutter below — and the comment-markers row is `pointer-events-none` (with each marker itself `pointer-events-auto`) so it no longer swallows most of that downward extension on an asset with an unresolved comment. The floating frame-preview/timecode tooltip is now clamped to the track's own width, since a captured drag can otherwise push it past the player's edge and over the comment panel. The playhead thumb, previously invisible until hover, is now always visible so there's something to see before you grab it. (#323 by @CamReport)
- **Opening an asset from a grid needed two taps on a touchscreen** — the grid, list view, and share-link folder browser all use a desktop file-manager convention where a click selects and a double-click opens; there's no discoverable double-tap gesture on a touchscreen, so every open genuinely took two taps. A single tap on a coarse (touch) pointer now opens the asset directly; a mouse keeps the exact original select-then-double-click behavior. That alone wasn't sufficient on any of the three surfaces: each asset card carries `group-hover:`/`hover:` styling (thumbnail zoom, a selection checkbox, a "⋯" or download-button overlay), and iOS Safari treats a tap on anything with a `:hover`-driven ancestor style as "entering hover" — the click event itself doesn't fire until a second tap, regardless of what the handler does. Every such class is now gated behind `(hover: hover)`, so none of those rules exist at all on a device that can't truly hover; the two button overlays that were invisible until hover (the "⋯" menu, the two download buttons) default to visible instead, since gating alone would make them permanently unreachable on touch. (#339 by @CamReport)
- **An interrupted upload no longer throws away everything it had already transferred** — the client fired `POST /upload/abort` from the `catch` of *any* upload error, not just a user cancel, and that calls `AbortMultipartUpload`, which discards every part the storage backend is holding. A network drop three quarters of the way through a 5 GB file therefore destroyed those bytes at the exact moment they became worth keeping. The abort is now sent only when the user cancels. A transfer that stopped on its own is reported as `Interrupted` rather than `Failed`, which is what it is: the multipart upload is still open and the parts are still there. Such a version is left for the stale-upload reaper to reclaim on its usual schedule if nothing picks it up. (#241) (#312 by @Lennart-Pingpong)
- **A stranded upload no longer sits at "Uploading 100%" forever** — a version the server still had at `uploading` was rendered by the uploads panel as a live transfer at full progress, with a Cancel button attached to nothing and a place in the Active tab and the sidebar badge. Nothing ever moved it, because the fallback poll only looked at versions in `processing`. Those rows now read `Interrupted`, sit alongside the other stopped uploads, and are reconciled against the server like processing ones. (#241) (#312 by @Lennart-Pingpong)
- **The uploads panel names the file it is uploading** — the row was titled with the asset's name, which is the same thing as the file only for a brand new asset. Uploading a new version showed the asset's name while a completely different file went up. The file is now named beside the project, and a version upload shows the project's name rather than the first eight characters of its id. (#312 by @Lennart-Pingpong)
- **The version switcher stops offering a version that was discarded** — it is refreshed by transcode events, and discarding an upload produces none, so the deleted version stayed in the list still labelled "Uploading". The panel now says when it has changed an asset's versions, after the server has answered — refetching earlier would race the request doing the deleting. Cancelling tells it too. (#312 by @Lennart-Pingpong)
- **Discarding an upload now throws it away instead of recording a failure** — Discard fired the same `/upload/abort` an error does, so the version stayed behind as `failed`: a red badge in the version switcher for an upload somebody deliberately threw away. Worse, that endpoint takes its own branch when the object turns out to be already whole, promoting the version and starting its transcode — so the one control that exists to discard an upload published it instead. A discard is now said explicitly, and the upload is disposed of the way the stale-upload reaper disposes of one: bytes gone, version gone, and the asset with it when that was its only version. The two share the code, so discarding by hand and being reclaimed a day later leave the same state behind. (#312 by @Lennart-Pingpong)
- **A second tab, or a second device, no longer offers to discard an upload that is still running** — rows in the uploads panel are kept in `localStorage`, which every tab shares, and a tab that loaded them rewrote every in-flight row to Interrupted and offered Discard. The discard succeeded, because the version really was still uploading, and the tab doing the transfer lost it on its next part. History rows did the same to an upload running on another device. A tab that is sending now stamps its rows, another tab or another device shows an upload that moved recently as Elsewhere with nothing to press, and before discarding or resuming an upload it did not start, a tab asks the server when it last moved and refuses if that was within the last two and a half minutes. Reloading the tab that was uploading still brings the row straight back as Interrupted, and so does a transfer that has just dropped under it — a tab keeps control of its own upload for as long as it was demonstrably the one sending, which is its own heartbeat rather than an id it wrote down once. Nothing a tab writes to that shared storage can roll back what another tab has said more recently about a row it is sending, or remove that row; and a row whose upload is running on another device is put back to Elsewhere by the poll when the other device is still moving, rather than staying on Interrupted with buttons that would destroy it. (#312 by @Lennart-Pingpong)
- **Discard asks first, and says so when it could not discard** — the parts are deleted the moment it returns and there is no way to get a version back, so it now confirms like deleting a project, folder or asset does. It used to take the row off the panel before the request and swallow every error, so unreachable storage left no row, no message and an upload the reaper would not collect for a day. The row now stays, with the reason. (#312 by @Lennart-Pingpong)
- **Cancelling an upload no longer leaves a failed version behind** — cancel sent the same abort a failure does, which records the version as failed: a red badge in the version switcher for an upload somebody stopped on purpose. It is thrown away like a discard now, and the version switcher refreshes. (#312 by @Lennart-Pingpong)
- **A discard can no longer delete an upload that finished while it was being discarded** — the discard decided on the version's status as it was read before the storage round trip, so a completion committed during that round trip went unnoticed and its assembled file was deleted with the transcode already dispatched. The status is re-read under a row lock. And the asset is only removed with its last version when the person discarding still holds the editor role on the project, as every other asset delete requires. (#312 by @Lennart-Pingpong)
- **The trash no longer fills with uploads that never landed** — an asset whose only upload was discarded or reclaimed is soft-deleted so it does not show in the grid as a card that cannot be opened. The trash listed it anyway, beside real deleted work, and restoring it brought that card back. The trash and restore now only take an asset that had a version reach processing. (#312 by @Lennart-Pingpong)
- **An upload row no longer takes another version's status for its own** — the fallback poll read `GET /assets/{id}`, which answers with the display version, and wrote whatever that said onto the row without checking it was the row's version. Uploading a second version to an asset whose first version is ready therefore rewrote that row to complete: the upload was reported as landed while it was not, and for an interrupted one the Resume and Discard controls disappeared with the parts still sitting in the bucket. Two rows on one asset also read the same answer, since the lookup was keyed on the asset rather than the row. This is the second half of #273. (#273) (#312 by @Lennart-Pingpong)
### Contributors
Thank you to @Lennart-Pingpong and @CamReport for the pull requests in this release, and to @loscarinos and @Paracid, whose issues drove fixes they did not write: #350 is why a download now hands you a proxy rather than the camera master, and #331 is why the review screen works in portrait on an iPhone.
## [1.13.0] - 2026-09-09
### Upgrade notes
- **`POST /auth/accept-invite` now requires `name`.** A request without it answers 422. The web
invite screen has always sent it, so nothing changes for anyone accepting an invite in a browser;
this only affects a script that provisions users by posting `token` and `password` directly.
- **Version numbers can now skip.** They are chosen across every version an asset has ever had,
including soft-deleted ones, so after a reclaimed or removed v2 the next upload is v3. This fixes
an asset becoming permanently unable to accept a new version. Anything reading `version_number`
should treat it as an identifier rather than a position; nothing in FreeFrame derives an index
from it.
- **New optional env var: `TRANSCODER_QUALITIES`** (default `1080p,720p,360p`, which reproduces the
previous behaviour exactly). Sets which ladder rungs a transcode builds.
- **A blank display name is now refused** on `PATCH /users/{user_id}`, which previously answered 200
and stored an empty string.
- **No migration required.**
### Added
- **The quality ladder is configurable** — every upload was encoded into 1080p, 720p and 360p, with the list hardcoded at the call site. `TRANSCODER_QUALITIES` (default `1080p,720p,360p`, unchanged behaviour) lets a deployment build fewer rungs. An instance whose reviewers all watch at full size pays for the smaller rungs in encode time and in storage without anyone ever playing them; a self-hosted box with no GPU feels both. Rungs above the source resolution are still dropped, so this cannot ask for more than the source has: a ladder left empty that way builds its smallest rung at the source's own size. An unrecognised rung name is reported and ignored rather than silently dropped, and a value with no valid rung left falls back to the full ladder — previously that produced `split=0` and an empty `-var_stream_map`, which ffmpeg rejects, so a single typo failed every upload through the whole retry ladder. (#319, #325 by @Lennart-Pingpong)
### Changed
- **A transcode's HLS output is now uploaded several files at a time** — the segments were sent one after another, and a PUT to a remote object store costs most of a round-trip whatever it carries. At the default segment length a 50-minute encode produces on the order of 850 files, so the worker spent that phase idle, waiting on latency rather than doing work. Measured against a real master (~3.9 GB source) over a remote store: 197s serially, 65s with eight threads. The gain is largest exactly where it is felt most, since a longer encode makes more segments and a more distant store makes each one cost more. Eight is not configurable, because botocore's default connection pool holds ten and raising one without the other would trade waiting on the network for waiting on the pool. A file that fails to upload now still fails the transcode rather than being lost in its thread. (#314 by @Lennart-Pingpong)
- **A share link to a single asset now opens the same review screen as one inside a folder** — the two paths rendered entirely different component trees, so sharing an asset on its own gave a plain video element and a bare comment box, while sharing the same asset inside a folder gave the real review stack. The single-asset path now renders that stack too, which brings it timecode-attached comments, annotation and mention controls, and a version switcher with version-scoped streams and comments. (#117, #123)
Two consequences worth knowing. Guests who had identified themselves on a single-asset link will be asked once more, because the two paths stored that identity under different keys and the shared one wins. And the single-asset page no longer shows the instance's logo and share name above the player, since the folder path's asset view never did; making both show branding is tracked separately.
### Fixed
- **A profile update can no longer store a blank display name** — `PATCH /users/{user_id}` trimmed the submitted name but never checked what was left, so a name of only whitespace answered 200 and stored an empty string, and the person then rendered as a blank author on every comment they had written, in the members list and in mention autocomplete. Reachable by anyone on their own account with no elevated role. The endpoint now uses the same `DisplayName` rule as the accept-invite screen, so the constraint has one home rather than being restated per call site, and a name longer than the column is a 422 instead of a database error. (#329)
- **The comment export control is no longer offered on a share link** — `exportComments` authenticates with a bearer token and nothing else, and a share-link guest has no token, so the request could only ever answer 401. The control was rendered for guests anyway: before export errors were surfaced the failure went to the console and nobody saw it, and after that a guest got a bare "Export failed (401)" in the panel. It is now absent on the share screen rather than present and guaranteed to fail. Giving guests a real export is a separate decision, since the export returns every comment on a version including author names, and a share link is a deliberately narrower grant than a member session. (#321)
- **An asset could be left permanently unable to take a new version** — the next version number was chosen by counting only the versions that were not soft-deleted, while the `uq_asset_versions_asset_version` constraint spans `(asset_id, version_number)` and has no view of `deleted_at`. A soft-deleted version still owned its number, so the same number was handed out again and the insert failed with a duplicate-key error, on every retry, permanently. Reaching it needed nothing unusual: the stale-upload reaper soft-deletes a version whose transfer stopped, so an asset that had had one upload reclaimed refused every upload after it. Numbering now spans every version an asset has ever had, which means numbers can skip (after a reclaimed v2, the next upload is v3) and are never reused while the row exists. Both handlers that create versions share one implementation, since they are near-verbatim copies and this is exactly the kind of fix that lands in one of them. (found by @Lennart-Pingpong while testing #312) (#334)
- **An invitee's name is now stored instead of discarded** — the accept screen asked for a name, validated it and sent it, and `AcceptInviteRequest` did not declare the field, so `extra="ignore"` dropped it and the new user was left with whatever the inviter's dialog had derived from their email address, which is the local part of it, on every comment they left. The field is declared now, and typed as `DisplayName`, which trims and rejects a blank the way `NewPassword` above it does: a length constraint alone would have counted `" "` as three characters and stored an empty name. (#320, #322 by @shaurya703)
**Contract change for anyone posting to this endpoint directly.** `name` is required, so a provisioning script that sends only `token` and `password` now gets a 422 rather than a user with a name derived from their email. The accept screen already sent it, so nothing changes for people going through the UI.
- **The last rung of a ladder no longer upscales past the source** — when every requested rung sat above the source resolution, the height filter emptied the ladder and the "never emit an empty ladder" fallback put the smallest one back unchanged, where `force_original_aspect_ratio=decrease` scaled it up: a 640×360 upload came out as one padded 1920×1080 rendition, costing more encode time and more storage than the source itself. The kept rung is now clamped to the source's own size. This closes the last way #201 could be reached, including through configuration. (#204, #201, #325 by @Lennart-Pingpong)
- **The comment export on the project page now acts on the asset that is selected there** — the panel read the review store, and nothing ever clears it: there is no unmount cleanup and the back arrow is a client-side navigation, so after opening any asset and returning it still held that one. On the project page the export then succeeded against the previously opened asset, downloading its comments under its name. On a cold load, where the store is genuinely empty, the same control silently did nothing and the NLE formats were missing from the menu. The panel now takes the asset and version to export as props where it is rendered outside the review screen, and says so instead of returning in silence when there is nothing selected. (#316, #326 by @Lennart-Pingpong)
- **Exporting comments to an NLE no longer produces a silently empty marker file** — a version whose comments carry no timecode exported to an EDL with a header and nothing after it. The download succeeded, the file opened, and DaVinci Resolve reported nothing to import, so the cause surfaced three applications away from where it was and looked like a broken export rather than an empty one. The export now refuses, and says which of the three reasons applies: the version has no comments, none of them are attached to a timecode, or the only timecoded ones were resolved and excluded. CSV is unaffected and still carries every comment, which is what the message points at. (#84, #326 by @Lennart-Pingpong)
- **A failed comment export now says so** — every error except the frame-rate prompt went to `console.error`, so an unsupported frame rate, a non-video asset and a version with nothing timecoded all produced a click that did nothing visible. The reason is shown in the comment panel, next to the control that failed. Structured error details from the API are read for their message rather than collapsing to "Export failed". (#326 by @Lennart-Pingpong)
- **Default logos no longer load an invisible second theme asset** — sidebar, authentication, share-password, attribution and branding-preview fallbacks now share one themed icon/full-logo component. A failed custom authentication logo also falls back to the correct built-in mark and retries when branding supplies a new URL. (#288 by @luozejian)
- **Focusing a comment input zoomed the whole page on iOS Safari** — Safari auto-zooms the page whenever focus lands on a text input with a computed font-size under 16px, and the main comment textarea, the reply input, the comment-edit textarea, the comment search input, and the guest name/email prompt on share links were all under that threshold. Each is now 16px on a touch device, reverting to its original compact size only under a `(hover: hover)` media query — not a `md` viewport-width breakpoint, which still zoomed on a landscape phone (an iPhone in landscape is wider than `md`'s 768px). (#324 by @CamReport)
### Contributors
Thanks to @Lennart-Pingpong, @CamReport, @shaurya703 and @luozejian, whose work is in this release.
## [1.12.0] - 2026-08-29
### Upgrade notes
- **New share links now allow commenting by default.** Existing links are unchanged, and downloads remain off by default. If you rely on links being read-only unless someone opts in, set `permission` explicitly when creating them.
- **New optional env var: `STUCK_PROCESSING_TIMEOUT_HOURS`** (default `6`). An hourly job re-dispatches transcodes for versions stuck in `processing` past this window. It is deliberately above the transcoder's own 4-hour ceiling so a slow encode is never given a second worker; set `0` to disable.
- **No migration required.**
### Changed
- **A new share link now allows commenting, and still does not allow downloading** — commenting is the core loop of a review link, so a viewer who cannot comment should be the deliberate case rather than the default. Downloads stay off: commenting affects what happens inside the review page, while downloading pulls the original out of it, and a share link is the most exposed surface there is. The API schema and the create dialog both changed, so links made through either route behave the same. Existing links are unaffected. (#266, decided with @rubenxyz)
### Added
- **A stuck transcode is now re-dispatched instead of sitting there forever** — `/upload/complete` marks a version `processing` and then schedules the transcode on a background thread, so a broker outage or an API worker recycled between the two left the version claimed with no task behind it. Nothing recovered that: the stale-upload reaper only sweeps `uploading` and `failed`, so the bytes kept counting against the storage cap and the upload sat in the Active tab indefinitely. An hourly sweep now re-dispatches versions stuck in `processing` past `STUCK_PROCESSING_TIMEOUT_HOURS` (default 6, above the transcoder's own 4-hour ceiling; `0` disables it). A version whose output already exists is marked `ready` rather than re-transcoded. (#270)
### Fixed
- **An upload's recorded size is now what storage actually holds** — `file_size_bytes` was taken from the client at initiate and never revisited, yet the per-file limit, the instance storage cap and per-project usage are all computed from it, so one wrong number stayed wrong forever. The size is reconciled against the assembled object when the upload completes, and the field is now bounded (positive, and at most what 10,000 parts can hold). This does not make the pre-upload guard binding, since the bytes have landed by the time it can be checked, but it stops the accounting from being permanently defeated. (#258)
- **A failing transcode no longer flips between `failed` and `processing` while it retries** — the task recorded `failed` before every retry, so a version oscillated for the whole retry ladder while its source file sat there intact, and the completion endpoint, the reaper and the client each got a different answer depending on when they asked. `failed` is now written when nothing further is coming: either the retries are spent, or the retry could not be queued at all. (#271)
- **Two concurrent completions can no longer both start a transcode for the same version** — the check that only an uploading version may be completed was an unsynchronised read, so two requests could both pass it and both dispatch, leaving two transcodes writing the same `processed/` prefix at once. The status change is now a conditional update, so exactly one request claims the version and only that one queues the work. Both callers still get the same successful answer. (#272)
- **The sign-in code screen no longer claims a code was sent when it may not have been** — entering an address with no account still advanced to the 6-digit step and stated "We sent a 6-digit code to …", so someone who mistyped their email waited for mail that could never arrive. The API answers identically for known and unknown addresses on purpose, to stop it being used to enumerate accounts, so the screen now says only what the server guarantees: if that address has an account, a code is on its way. A typo hint sits next to the input, and "Use a different email" is a real button rather than tertiary text. No response changes, so the anti-enumeration property is untouched. (#248)
- **An audio-only file in a video container now uploads instead of failing to process** — browsers type a `.mpg` or `.mp4` that carries only an audio track as `video/*`, so it was routed to the video pipeline. Every rung of the quality ladder was filtered out against a source height of zero, the "never emit an empty ladder" fallback put one back, and ffmpeg then died on `[v:0] matches no streams`, leaving the upload in `failed` with an error that said nothing about the cause. The transcoder now reports a missing video stream as its own outcome and the asset is re-typed and run through the audio pipeline. (#82)
### Contributors
No external PRs this release, but several of these came from other people's reports and reviews: @casinojeffrey found the audio-only upload failure (#82), @rubenxyz's reasoning decided the share-link defaults (#266), and @Lennart-Pingpong's earlier upload-reliability work is what surfaced the completion and status-lifecycle problems fixed here.
## [1.11.0] - 2026-08-29
### Added
- **Real-time comments, approvals and transcode progress now actually arrive.** The web app has always subscribed to six SSE events, but the backend only ever published two, so a new comment, a resolved comment, an approval and transcode progress never reached anyone watching: the page sat waiting for events nothing sent. All six are now published, through a single emit path. Comment and approval events are sent by the request that made the change, after it commits; progress is reported as the encode advances, at most once per whole percent. A broker that is down or slow degrades to "no live update" rather than failing the write that triggered it. (#294)
- **Optional hardware-accelerated transcoding (NVENC / VAAPI) with HDR and Dolby Vision support.** Auto-detects an available GPU backend and falls back to the software pipeline when none is present, or when a hardware attempt fails at runtime (for example, out of VRAM). CPU-only and arm64 builds are unaffected by default. New env vars: `TRANSCODER_PIPELINE`, `TRANSCODER_OUTPUT`, `TRANSCODER_HDR`. (#127 by @deezid)
### Fixed
- **Switching a version in compare no longer leaves the previous version's drawing on screen** — a comment's annotation stayed painted over the pane after that pane moved to a different version, anchored to a frame that was no longer there. It is now cleared when the pane showing it switches, and left alone when the other pane switches. (#181 by @vandit-bera)
- **Switching a version in compare no longer applies the previous pair's sync offsets** — offsets calibrated for one pair of versions stayed in the URL after switching either pane, silently misaligning the new pair. They are now cleared on a real switch, and left alone when you re-pick the version a pane already shows. (#182 by @vandit-bera)
- **Branding UI remains readable and correct across themes** — custom muted accents are now opaque, selected chips and controls use readable theme text, default logos pair light/dark artwork in previews and password gates, and the Apple home-screen icon uses its dedicated asset. (#275 by @luozejian)
- **Navigating the UI no longer throws a `null is not an object (evaluating 'instance.parentNode.removeChild')` error** — Next auto-generates `<link rel="icon">` / `<link rel="apple-touch-icon">` tags from the `app/` icon files, and those are React-managed hoistable resources. `BrandingHead` removed them with `el.remove()`, leaving React to unmount a node whose parent was already gone on the next commit (e.g. during navigation). `favicon.ico` and `apple-icon.png` now live in `public/` and are still served at the same URLs (browsers request `/favicon.ico` on their own) and `icon.png` (a duplicate of `public/logo-icon.png`) was removed, so Next no longer emits the React-owned links and `BrandingHead` is their sole owner. (#286 by @Thedude7054)
- **The default full logo was drawing one wave short** — `logo-full.svg` and `logo-full-dark.svg` were each missing the third arc of the FreeFrame mark, so the login page, the branding preview and any share page falling back to the default rendered a dot and two arcs instead of a dot and three. The path was present in `logo.svg` and in the master artwork all along and had simply been dropped when the two full-logo variants were derived from it. The raster assets (`logo-full.png`, `logo-icon.png`) were always correct, which is why the mark looked right in the sidebar and the attribution badge but wrong on the login screen.
### Changed
- **`docs/architecture.md` corrected against the code.** Its Permission Model documented an organization and team hierarchy that no longer exists, and an asset-access order ending in "shared with the user's team" and "is the user an org admin" — neither of which the code checks — while omitting public projects entirely. The database section claimed all tables soft-delete and that nothing hard-deletes, and drew `Organization` as the schema root. The video pipeline described one thumbnail per ten seconds, a waveform, and an S3 output prefix that were all wrong. The SSE table lists the events and their payloads.
- **`AGENTS.md` corrected.** It stated that soft delete was universal and that nothing hard-deletes, which is true of neither: 13 of 29 models carry `deleted_at`, nothing filters it automatically, and the retention GC does hard-delete. It also implied hot reload covered the whole dev stack (it covers `api` and `web` only, so task edits need a worker restart) and did not warn that the dev stack's scheduled cleanup really deletes local data.
### Removed
- **Dead soft-delete middleware removed** — `apps/api/middleware/soft_delete.py` exported `apply_soft_delete_filter` and had zero call sites. It shipped in March alongside the org admin dashboard and was never wired up; every query filters `deleted_at` by hand. Keeping it invited the assumption that filtering happens automatically, which it does not.
- **Dead dashboard home page removed** — `app/(dashboard)/page.tsx` was unreachable. `(dashboard)` is a route group, so it contributes no path segment and that file resolved to `/`, the same route as `app/page.tsx`, which redirects to `/projects` and won. The page had never rendered since it was added in March; its only later change was a Prettier pass. Removing it also clears the `Failed to copy traced files ... page_client-reference-manifest.js` warning `next build` emitted for the discarded duplicate route.
- **Dead share dialog code removed from the review view** — `components/review/share-dialog.tsx` still carried the pre-dropdown tab components (`LinkTab`, `DirectTab`) and their helpers (`PermissionSelect`, `CopyButton`), unused since the April dropdown redesign. The live dialog creates links through `ShareCreateDialog`, which already includes the Link name field; the leftover code is what suggested otherwise. Removed to keep the file truthful. (#265 by @rubenxyz)
### Contributors
Thanks to @deezid (#127), @vandit-bera (#238, #239), @Thedude7054 (#286), @luozejian (#277, shipped as #292), @rubenxyz (#282) and @bingqiling007 (#284) for contributing to this release.
## [1.10.0] - 2026-08-21
### Contributors
Thanks to @rubenxyz (#202, #203) and @Lennart-Pingpong (#263).
### Upgrade notes
- **`POST /upload/complete` now refuses a request whose `asset_id` is not the asset the version belongs to**, with a `400`. Only relevant if you drive the API with something other than the FreeFrame web client, which always sends back the id it was handed at initiate.
- **`/upload/complete` and `/upload/abort` can now answer `503` with `Retry-After`.** Previously an unreachable object store surfaced as a `500` on those paths, or as a `409` on a retried completion. A `503` means the request was not acted on and is safe to send again; a `409` continues to mean the upload genuinely cannot be completed. Only relevant if you drive the API with something other than the FreeFrame web client, which treats both as a failure today.
### Added
- **Instance branding — white-label your FreeFrame** — admins can set the org name, light/dark logos, favicon, apple-touch icon, login-page logo, and a primary accent color from Settings → Branding, with a toggle to hide the "Powered by FreeFrame" badge. The org name and logo appear across the app (sidebar, login, share pages, emails, page titles), and emails are de-branded so invitations come from the org rather than FreeFrame. Logos upload straight to storage via presigned URLs and are served back as short-lived presigned links. New endpoints: `GET/PUT /instance/branding`, `POST /instance/branding/{logo_type}-upload`, `DELETE /instance/branding/logo/{logo_type}`. (#203)
### Changed
- **Bucket CORS rules are tightened and the public-read bucket policy is gone** — the automatic bucket setup now pins `AllowedHeaders` to the headers browser uploads actually send (`Content-Type`, `Content-MD5`, `x-amz-content-sha256`, `x-amz-date`, `x-amz-decoded-content-length`) instead of `*`, and drops `DELETE` (no presigned-DELETE flow exists). It also no longer publishes a public-read policy on `processed/*`: HLS playback goes through the presign proxy, so the policy was dead weight that exposed any processed object to anyone who guessed a key. `docs/deployment.md` has been corrected to match. (#202)
### Fixed
- **A completion could steer its transcode into another project** — `POST /upload/complete` took the request's `asset_id` on trust and handed it to the transcode task, which resolves that asset with no cross-check of its own and derives both the storage prefix it writes to and the event stream it publishes on from whatever it finds. The owner of any version could therefore name someone else's asset and have their own transcode output written under that project's prefix, with a `transcode_complete` event fired on its stream. The id is now checked against the version before anything runs, and the dispatch reads the version's own asset rather than the request's. (#267)
- **An upload whose completion response was lost is no longer reported as Failed** — the client awaited `/upload/complete` once and treated any error as the upload having failed, but a completion whose response went missing on the way back (a dropped connection, a closed laptop, a proxy timeout) has already been committed and its transcode started. The file processed and became available normally; the uploads panel just said Failed about it, offered Dismiss rather than Retry, and persisted that row across reloads, where it then shadowed the real asset in the panel's history. (The server was never at risk: `/upload/abort` has left an already-`processing` version alone since 1.9.0.) The client now asks what actually became of the version before deciding, and asks again after aborting, since the abort itself can promote a version whose object was assembled but whose status write never landed. The check is keyed on the version, so an interrupted new version of an existing asset is still correctly reported as failed rather than reading its predecessor's status as success. (#269)
- **The instance accent color now actually paints the app** — it was written to a `--ff-primary` CSS variable that nothing read, so the picker validated and saved a color that changed nothing on screen. It now sets `--accent` and derived hover/muted shades, which every accent surface resolves through: primary buttons, links, focus rings, and the download and post-comment buttons a guest sees on a share link. A share link's own accent still wins on its own page.
- **A custom favicon now replaces the default instead of competing with it** — Next emits its own icon link and serves `/favicon.ico` by convention, so appending another left the custom icon as one candidate among several and it usually lost. Any icon link the app doesn't own is now removed first.
- **Share links honour their own theme and accent on the single-asset viewer** — a link's appearance was only applied on the folder grid; the asset viewer was never passed it, so a link set to the light theme with its own accent opened dark in the instance's colors. The viewer's fixed greys are now theme tokens, so the light theme produces a genuinely light page rather than a half-flipped one.
- **The share page no longer shows "Powered by FreeFrame" twice** — the asset viewer's top bar carried a second copy alongside the footer's.
- **Logo fallbacks use the real brand mark** — the app shipped placeholder "FF" SVGs and pointed every fallback at them, so resetting branding fell back to a placeholder rather than the actual mark. The attribution badge now uses the same artwork as the favicon, with no wordmark, and links to the repository.
- **The sidebar picks the right logo variant under the system theme** — `system` is neither `light` nor `dark`, so comparing it directly handed a system-light viewer the dark-background logo on a light page.
- **Passwords are bounded at 8–72 characters everywhere they are set** — set-password, invite-accept, and admin-created accounts now require at least 8 characters and reject more than 72 with a clear validation error instead of letting bcrypt silently truncate (a longer password would otherwise authenticate against only its first 72 bytes). Login deliberately enforces only the 72-byte ceiling so legacy accounts with shorter passwords are not locked out before auth runs. (#202)
- **User search treats `%` and `_` literally** — search terms are LIKE-escaped so wildcard characters match themselves instead of fanning out a search. (#202)
- **`PATCH /auth/me/preferences` accepts only known keys** — it previously took an arbitrary dict, so anything stored could later be a stored-XSS surface if ever rendered back unescaped. Preferences are now validated against a fixed schema of primitive values, merged over existing prefs so unspecified keys are preserved. (#202)
- **`POST /auth/refresh` is now rate-limited** (30/minute per IP) — previously only the generic global write limiter backed token refresh. (#202)
- **Login redirects respect a configured base path** — deployments mounted at a sub-path (e.g. `/freeframe/`) no longer bounce to the bare `/login` and trigger a catch-all redirect chain when tokens are cleared. (#202)
- **Retrying a completion whose response was lost no longer reports a successful upload as failed** — `/upload/complete` answered any already-finished version with 409, including the common case where the first call committed `processing` and the response never made it back. The idempotency check that exists for exactly this (`_already_assembled`) sat behind the guard and was unreachable, so a retry told the user their upload had failed while the transcode of that same file was running. The endpoint now asks storage first: if the assembled object is at the key at the declared size, it reports the version's current status instead of a conflict. Replay protection is unchanged — the status write and the transcode dispatch still only ever run from `uploading` — and a version that is `failed`, or one whose object is short or absent, still gets the 409. (#263 by @Lennart-Pingpong)
- **A storage lookup that cannot be completed is no longer read as "the object is not there"** — three paths could not tell the difference. A `HeadObject` that was refused, never reached the backend, or came back without a `ContentLength` (which happens behind proxies that strip or chunk-encode HEAD responses, and used to raise an unhandled `KeyError`) all resolved to "absent", so a retried completion was told to upload the whole file again over a momentary hiccup. Not knowing is now its own answer, and every path that has to give one answers `503` with `Retry-After` rather than a `409` that says the request can never succeed. The lookup also runs on its own client with short timeouts, since it sits on the request path of an endpoint family the rate limiter exempts and previously inherited boto3's ~60s-per-attempt defaults. (#263 by @Lennart-Pingpong)
- **Listing an upload's parts and completing it now survive a storage blip** — neither had any handling for never reaching the backend, as opposed to being refused by it, so a connection wobble at the end of a long transfer surfaced as a `500`. Both answer `503` with `Retry-After` instead, and a completion that timed out having actually succeeded is recognised as done rather than retried. (#263 by @Lennart-Pingpong)
## [1.9.0] - 2026-08-16
### Upgrade notes
- **A database migration is required this time.** Run `alembic upgrade head` (revision `b2c4d6e8f0a1`). It adds two nullable columns to `asset_versions` and one index, with no backfill, so it is quick and takes no heavy lock — but the API will not work against an unmigrated database. The previous release said no migration was needed; this one does need it.
- **`STALE_UPLOAD_TIMEOUT_HOURS` starts applying on MinIO for the first time.** Reclaiming abandoned uploads was driven by a bucket listing that MinIO serves from a node-local in-memory cache, so it found nothing after any restart and the setting was silently inert. Abandoned uploads were only ever cleaned up by MinIO's own ~24 hour expiry. If you set a value expecting it to be honoured, it now is; if you set a short one, uploads will start being reclaimed on your schedule rather than MinIO's.
- **Long-running uploads are no longer reclaimed while they are still transferring.** The same sweep aged uploads by when they started rather than by whether anything was still happening, so on the backends where it did work it could abort an upload mid-transfer. If you raised `STALE_UPLOAD_TIMEOUT_HOURS` to work around that, you can put it back.
- **`NEXT_PUBLIC_UPLOAD_CONCURRENCY` (default `5`) is new**, and controls how many parts of one file upload at once. Like every other `NEXT_PUBLIC_*` value it is read at build time, so changing it needs a web image rebuild rather than just a restart. Lower it if your object store throttles concurrent part uploads.
- **`POST /upload/complete` now rejects an incomplete parts list.** It previously accepted one and quietly produced a truncated file. Only relevant if you drive the API with something other than the FreeFrame web client; the browser client is unaffected.
- **`ExposeHeaders: ETag` in your bucket CORS is no longer required**, though it remains recommended and harmless. `docs/deployment.md` has been corrected.
### Changed
- **Large uploads send several parts at once instead of strictly one after another** — up to 5 parts are now in flight per file, so an upload no longer pays a full presign round-trip and a TCP ramp-up for every 10 MB chunk in turn. **This helps only where a single stream cannot fill the link**, which means fast connections and object stores that are far away; on a bandwidth-limited uplink one stream already saturates the line and the upload will take exactly as long as before. Tune with `NEXT_PUBLIC_UPLOAD_CONCURRENCY` (default `5`) if your storage backend throttles concurrent part uploads; it is read at build time, so changing it needs a web image rebuild. Part order is preserved regardless of the order parts finish in, and progress counts completed parts rather than the highest one started. (#242)
- **`media_files.s3_key_raw` is indexed.** `/upload/presign-part` looks it up once per part, which was a sequential scan per chunk of a table that grows with every version ever uploaded — 10,000 of them on a large upload, several at a time now that parts upload in parallel.
### Fixed
- **Cancelling an upload that had actually finished no longer discards it** — `CompleteMultipartUpload` can succeed with the process dying before the version's status is written, which leaves a fully assembled file and a version still reading `uploading`. The client sends `/upload/abort` from the failure path of every completion, so that version was marked failed and the stale-upload reaper deleted the finished file a day later. Aborting now asks storage whether the object is already there at the expected size, and records the upload as processing instead, dispatching the transcode that never ran.
- **Abandoned multipart uploads are now reclaimed from FreeFrame's own records rather than by listing the bucket** — the old sweep aborted any in-progress upload older than `STALE_UPLOAD_TIMEOUT_HOURS`, without checking whether anything still referenced it, which meant it could abort an upload that was still transferring. It was also silently doing nothing at all on MinIO, the default backend, whose upload listing is served from a node-local in-memory cache and comes back empty after any restart, so the configured timeout never applied there. Each stale version's own upload is now aborted directly, which works on every backend. The bucket listing is kept only as a best-effort second pass for uploads that no record owns, such as one left behind by an initiate that failed partway.
- **A failed upload now reports straight away instead of up to four minutes later** — when one part failed for good, the other parts in flight were left to climb their own retry ladders before the error surfaced, so a doomed upload kept sending chunks for as long as ~255 seconds, the progress bar kept advancing, and the version sat at `uploading` the whole time. Sibling parts are now stopped as soon as a part fails, including any waiting out a backoff, since every error after the first is discarded anyway. Cancelling during that window also no longer shows the upload flipping from Cancelled back to Failed.
- **A dropped part no longer produces a silently truncated file** — the server now reads an upload's parts from storage and verifies them before completing it, instead of trusting the list the browser sends. Completing with a gap is not an error on most S3 backends: we confirmed that MinIO accepts a list naming only part 1 of a two-part upload, returns success, and leaves a half-length file that transcodes normally and shows as ready, with the rest of the bytes discarded. The server now checks that part numbers run 1..N with no holes, that every part but the last is the same size, and that the sizes add up to the size the client declared, and refuses the upload with a message naming the specific part if not. A refusal is logged with the version, the key and the byte counts, since it can happen after a long transfer.
A side effect worth knowing if you self-host: the browser no longer has to be able to read the `ETag` response header from your bucket, so `ExposeHeaders: ETag` in the bucket CORS is now recommended rather than required (see [#131](https://github.com/Techiebutler/freeframe/issues/131), which documented that requirement). It still applies on a backend with no `ListParts`, which is the one case where the client-reported list is used, and that case is logged. `docs/deployment.md` has been corrected.
- **Retrying a completed upload no longer risks deleting it** — `CompleteMultipartUpload` consumes the upload, so a retry after a lost response failed, left the version at `uploading`, and the stale-upload reaper deleted the finished file a day later. A retry is now recognised as already-done by checking the stored object's size. Completing a version that is no longer uploading is refused outright rather than rewinding it and re-running the transcode. `POST /upload/abort` likewise marks the version failed when storage has already discarded the upload instead of erroring and leaving it stuck at `uploading`, while a real storage failure is now surfaced rather than swallowed, and a version that already finished is left alone.
- **A slow upload is no longer aborted while it is still running** — the stale-upload reaper aged uploads by when they *started*, so anything slower than `STALE_UPLOAD_TIMEOUT_HOURS` (default 24) was reclaimed mid-transfer. On the connections this project exists for that is not a corner case: a 90 GB master at 8 Mbit/s takes longer than a day. Versions now record when a part was last requested and are aged by that, falling back to creation time for uploads that predate the column.
- **`/upload/presign-part` no longer signs an arbitrary upload id** — the endpoint passed whatever upload id the caller sent straight to the signer, because nothing recorded which S3 upload a version actually belonged to. The upload id is now stored at initiate and checked here. It also no longer signs parts for a version the reaper has already soft-deleted, which wrote bytes to a key nothing owned. Neither was exploitable across accounts, since the key must already belong to the caller.
- **An interrupted upload no longer makes the whole asset look broken** — the asset surfaces showed the highest-numbered version regardless of its state, so starting a v2 on an approved asset made it read as uploading or failed until the reaper cleaned up, up to a day later, while v1 sat there ready and perfectly viewable. Listings and the detail view now show the newest version a viewer can actually use, falling back to the newest overall so a brand new asset still reports that it is uploading.
- **A version being uploaded no longer makes earlier versions unplayable** — asking to stream or download an asset without naming a version picked the newest one and then refused it as not ready, with no way to reach v1 short of knowing its id. It now serves the newest *ready* version, which is how the share-side viewer already resolved it.
- **A failed upload no longer comes back as a card that cannot be opened** — when the reaper reclaimed an asset's last remaining version it left the asset itself in place, and because the project grid deliberately shows assets that have no versions yet, a stripped asset became indistinguishable from a newly created one and reappeared a day after the failure. An asset left with no live versions is now soft-deleted with them.
### Contributors
Thanks to @Lennart-Pingpong, whose concurrent-upload implementation ships here (#242 via #250), and whose reports of #241 and #242 started the investigation that turned up most of the rest.
## [1.8.0] - 2026-08-15
### Upgrade notes
- **The maintenance worker is a new service, so rebuilding is not enough.** Scheduled housekeeping now runs on a dedicated `maintenance` queue with its own `maintenance_worker` container. You must be using the updated `docker-compose.prod.yml`; `docker compose up -d --build` against your existing file leaves the tasks with no consumer, exactly as before. Optional `MAINTENANCE_CONCURRENCY` (default `1`) tunes it.
- **The retention GC has never actually run, so its first pass may delete a lot at once.** Before starting the new worker, check what it will reclaim, and note that a soft-deleted project takes its live assets with it. `docs/deployment.md` has the query and the procedure. The deletion is a hard delete with S3 reclaim and is not recoverable. Set `SOFT_DELETE_RETENTION_DAYS=0` to keep it off.
- **Drop the stale `default` backlog.** Instances that ran `celery beat` accumulated roughly 49 orphaned messages a day. The new worker consumes `maintenance`, not `default`, so the backlog is inert and safe to rename away rather than drain. See [Updating](docs/deployment.md#updating).
- **Existing image annotations will move.** They were stored in container coordinates and are now migrated on read to where they were actually drawn on the picture. The new position is the correct one and no longer shifts when the viewer is resized, but it will differ from what you saw before upgrading.
- **No migration required** — no schema changes in this release.
### Fixed
- **Image annotations no longer drift when the viewer's aspect ratio changes** — drawings on images were authored and replayed against the full letterboxed container box rather than the picture inside it, so the same annotation pointed at a different part of the image depending on where it was viewed: sidebar collapsed vs expanded, a resized window, a compare pane, or a share link. A 3:1 image annotated in a 900x500 viewer and reopened in a 400x700 one put the mark 54% of the image height away from where it was drawn, far enough to land off the picture entirely. Images now get the same treatment video already had (`VideoFrameConstraint`), applied in the single viewer, both side-by-side compare panes, and the wipe view. Annotations saved before this change are migrated on read rather than left behind: the picture box inside their original container is reconstructed from the image's intrinsic size, so an old mark is re-expressed relative to the picture and lands where it was actually drawn, at any viewer size. **This means existing image annotations can appear in a different place after upgrading than they did before** — the new position is the correct one, and it no longer moves when the viewer is resized. New annotations record which coordinate space they use, so no future reader has to guess. (#185)
- **A single transient storage error no longer discards an entire multipart upload** — each part is now retried up to 8 times with exponential backoff and jitter (≈254s of tolerance per part) instead of failing the whole upload on the first non-OK response. A multi-gigabyte file is several hundred sequential requests, so one 500/503 from the object store or a brief network drop was enough to throw away everything already transferred; on a slow uplink that can be an hour of work. The presigned URL is re-fetched on every attempt, since `presign_upload_part` expires after an hour and a large upload can outlive that. User-initiated cancels are never retried, and a 4xx is thrown straight through instead of being repeated eight times over four minutes — it would fail identically every time, so retrying it only delays the error. The part loop, previously duplicated between new-asset and new-version uploads, is now shared.
- **A machine going to sleep no longer silently kills an upload** — the browser is now asked for a Screen Wake Lock while an upload is in progress, and releases it when the last one finishes. An upload lives entirely in the tab, so a suspend mid-transfer kills the loop pushing chunks; worse, nothing runs afterwards, so the `/upload/abort` call never happens and the version is left at `processing_status = 'uploading'`, which in the UI is indistinguishable from a stalled transcode. Best-effort by design: without HTTPS, in low-power mode, or in a browser without the API there is simply no lock, which never blocks the upload. The lock is re-acquired on `visibilitychange`, since browsers drop it whenever the tab is hidden.
- **Scheduled maintenance tasks now actually run** ([#240](https://github.com/Techiebutler/freeframe/issues/240), reported by @Lennart-Pingpong) — no worker consumed the `default` queue, and five tasks routed there: the retention GC (`cleanup_soft_deleted`), the stale-upload reaper (`reap_stale_uploads`), the orphan sweeper (`sweep_orphan_s3`), due-date reminders (`send_due_date_reminders`), and `apply_watermark`. `beat` published them on schedule, Redis accumulated them, and nothing ever executed them. Nothing looked broken: every container reported healthy and no error appeared in any log, so on an affected instance soft-deleted rows and their S3 objects were never reclaimed, abandoned multipart uploads were never aborted, and `POST /admin/purge` returned success while doing nothing. The four scheduled tasks now route to a dedicated `maintenance` queue with its own `maintenance_worker` service, kept separate from transcoding (where housekeeping would hold a CPU slot while a video waits) and from email (where it would sit in front of a magic code someone is waiting on). Tune with `MAINTENANCE_CONCURRENCY` (default `1`). `apply_watermark` goes to `transcoding` instead, since it is dispatched from a request handler and is a full FFmpeg re-encode rather than housekeeping.
This corrects earlier entries below, which stated that the retention GC and the upload reaper "activate if you run `celery beat`". Running `beat` was necessary but not sufficient; a worker consuming the queue was also required, and none shipped. **Upgrading requires the updated compose file, not just a rebuild**, and instances that have never run the GC should check what its first pass will delete before starting the new worker. See [Updating](docs/deployment.md#updating).
### Changed
- **Image and video overlays now share one contain-fit implementation** — the geometry that positions an annotation over the visible picture was written twice, once for `<img>` and once for `<video>`, with the two comparing aspect ratios differently. Both now call a single tested helper that measures the element's own box, which is correct whether the element fills its container (`w-full h-full`, the main video player) or only ever shrinks to hug the media (`max-*`, every image and the compare-pane videos). That last case was previously fitted against the container, which upscaled the box and misplaced annotations on any compare-pane video smaller than its pane. The overlay also spans the element instead of collapsing to 0x0 when measured before layout, and re-measures when its box changes, so an annotation opened from a deep link before the image has decoded is no longer scaled against the wrong box.
- **Pinned `starlette` and `botocore` so self-hosted Docker builds are reproducible** — both were floating transitives, so rebuilding the same commit on a different day could silently install different versions with no diff and no PR. FastAPI declares `starlette>=0.46.0` with no upper bound, meaning builds were free to cross a Starlette major (the ASGI layer under the SSE endpoint and the middleware stack); `botocore` is where S3 request signing lives, and unreviewed moves there have broken S3 compatibility before. Both are pinned to the versions already resolving, so no installed version changes.
### Contributors
Thanks to @Lennart-Pingpong, whose upload-reliability work (#243, #245) ships here and whose report of [#240](https://github.com/Techiebutler/freeframe/issues/240) uncovered that scheduled housekeeping had never executed on any deployment.
## [1.7.5] - 2026-07-23
### Fixed
- **Version switch clears active comment annotation** — switching versions in the single-asset review view now clears the focused comment and active drawing overlay, preventing drawings from previous versions from lingering on top of the newly selected version. (#198 by @Vrindakr3300)
- **HLS renditions no longer upscale past the source resolution** — `force_original_aspect_ratio=decrease` preserved aspect ratio but not scale, so a 640×360 source was still producing padded, blurry 1080p/720p renditions. The quality ladder is now filtered against the actual source height (ffprobe'd before transcoding), falling back to the smallest requested rendition rather than an empty ladder if the source is smaller than everything requested. (#204 by @siddharthgoyal00)
- Server-side password length validation on change-password endpoint. (#179 by @An-Array)
### Changed
- Password changes now revoke all other sessions by incrementing token version; current session receives fresh tokens to stay logged in. (#179 by @An-Array)
### Contributors
Thanks to @An-Array, @Vrindakr3300, @siddharthgoyal00, and @solankimeet518 for contributing to this release! (@solankimeet518's #199 hardened the `matchMedia` test stub — no user-facing entry above, but very much appreciated.)
## [1.7.4] - 2026-07-23
### Fixed
- **A superadmin can no longer delete or deactivate their own account and get irrecoverably locked out** — `DELETE /users/{id}` gained the same self-protection `PATCH /admin/users/{id}/deactivate` already had. Removed two duplicate, unused endpoints (`/users/{id}/deactivate`, `/users/{id}/reactivate`) that had no such guard and no frontend caller — the admin dashboard already uses the correctly-guarded `/admin/users/{id}/...` versions.
- **`/auth/login` is now rate-limited** (10 attempts / 10 minutes per IP) — previously only the generic global write limiter (300/minute) backed password login.
- **`/auth/verify-magic-code` no longer reveals whether an email is registered or deactivated** — an unknown email, a deactivated account, and a wrong code now all return the same generic 401, matching how `/auth/login` already avoids this.
- **Internal-visibility comments no longer reach public share links** — a comment (or reply, at any thread depth) marked "internal" is team-only by design, but the guest share endpoints didn't filter on visibility at all.
- **`@mention`ing a user in a comment now requires that user to actually have access to the asset** — previously any mentioned user id (or parsed `@email`) got a real notification and email with the asset name and a comment preview, regardless of whether they could see the asset at all.
- **Replying to a comment now checks the parent comment belongs to the same asset** — `POST /assets/{id}/comments/{comment_id}/replies` looked up the parent by id alone, so a comment id from an unrelated asset (including one the caller has no access to) would be accepted, letting a caller both probe whether an arbitrary comment id exists and inject a reply into a thread on an asset they can't see.
- **`POST /assets/{id}/comments` now validates that `version_id` actually belongs to the asset** — previously accepted unchecked, which could create a comment whose `asset_id` and `version_id` referred to two different assets.
## [1.7.3] - 2026-07-23
### Fixed
- **Public share-link comments can no longer read or write outside the shared scope** — both `GET /share/{token}/comments` and `POST /share/{token}/comment` trusted a client-supplied `asset_id` for folder/project-scoped links (and, for the write endpoint, even for a single-asset link — a request body could name a different asset entirely) with no check that the asset was actually within the link's scope. A holder of any comment/approve-permission share link could read or post comments on assets never shared with them. Both endpoints now validate the resolved asset against the share link the same way every other public share endpoint already does.
- **Password-protected share links now actually gate comments** — the two comment endpoints were the only public share routes that skipped password/session verification, so a password-protected link's comments were fully readable and writable with just the token.
## [1.7.2] - 2026-07-23
### Fixed
- **`GET /users` and `GET /users/search` no longer expose pending invite tokens** — both endpoints reused the same response shape as the admin user list, which includes a pending invitee's live `invite_token`. Since either endpoint only required being logged in (not an admin), any authenticated user could look up a pending invitee and read their token, then complete the invite themselves via `/auth/accept-invite` before the real invitee did. The token is now only ever returned by the admin-gated `GET /admin/users` and `POST /users/invite` responses.
- **Removed the legacy `POST /auth/register` endpoint** — it created an immediately-active, loginable account for any email with no auth and no invite check, bypassing the invite-only model this platform is built around. It had no frontend caller; account creation now only happens via an admin invite (`/users/invite` → `/auth/accept-invite`), first-time setup (`/setup/create-superadmin`), or a sign-in code sent to an already-known email (`/auth/send-magic-code`).
## [1.7.1] - 2026-07-23
### Fixed
- **Sign-in codes are no longer issued to uninvited emails** — `POST /auth/send-magic-code` used to create and, on verification, fully activate an account for any email address, whether or not an admin had invited it. Only an existing user (already active, or already invited via `POST /users/invite`) can now receive a working code; an unrecognized email gets the same generic response, so the endpoint still doesn't reveal which emails are registered.
## [1.7.0] - 2026-07-21
### Upgrade notes
- **AWS S3 users in `us-east-1`: no action needed, but presigned URLs change signature.** They are now SigV4 rather than SigV2. If browser uploads were failing with an opaque 403 (`SignatureDoesNotMatch`), this release fixes it — any workaround you added can be removed.
- **Self-hosted S3 (Garage, MinIO, Ceph, R2/B2/Spaces): path-style addressing and SigV4 are now forced** in non-AWS mode (`S3_STORAGE` ≠ `s3`). This is what makes Garage work out of the box. If you deliberately relied on virtual-host-style addressing against a backend that supports it, be aware the request shape changes.
- **`uvicorn` 0.30.6 → 0.51.0** is a large jump. If you override the server command or its flags in your own compose file or image, re-check them against the upstream changelog. The bundled `apps/api/Dockerfile` invocation is unchanged and verified.
### Added
- **Configurable CORS origins** — a new `CORS_ALLOW_ORIGINS` setting (comma-separated) lets the API allow browser origins beyond the built-in frontend/localhost defaults; set it to `*` to allow any origin (handy when testing over a LAN IP — not recommended in production). The wildcard is served by echoing the request origin, so credentialed requests keep working.
### Changed
- **Dev compose honors the environment for LAN/self-host testing** — `docker-compose.dev.yml` now reads `NEXT_PUBLIC_API_URL`, the S3 storage credentials/bucket/region, `S3_PUBLIC_ENDPOINT`, and `MINIO_CORS_ALLOW_ORIGIN` from the environment (falling back to the previous defaults), so the dev stack can point at a LAN IP or external storage without editing the compose file.
- **Uploads panel now defaults to the Active tab** — opening the panel after an upload shows only in-progress items instead of dumping the full upload history on screen. Switch to the All/Complete/Failed tabs to view history as before.
- **Dependency updates** — uvicorn 0.30.6 → 0.51.0, alembic 1.13.3 → 1.18.5, httpx 0.27.2 → 0.28.1, bcrypt 4.2.0 → 4.3.0, psycopg2-binary 2.9.11 → 2.9.12 (plus frontend/dev: `@types/node`, `@vitejs/plugin-react`, and three `@radix-ui` packages).
### Fixed
- **First-time sign-in no longer breaks at the set-password step** — after verifying a magic code, a brand-new user (one who hasn't set a password yet) was advanced to the "set password" screen but the tokens issued by `verify-magic-code` were discarded, so the follow-up `POST /auth/set-password` (which requires an authenticated user) returned 401 and bounced the user back to the login screen — never able to finish onboarding. The tokens are now persisted before the set-password step. The password-login form also validates the email format client-side, so a malformed address shows a friendly "Enter a valid email address" instead of surfacing the raw backend validation message.
- **"Copy invite link" works over plain HTTP / LAN** — the admin users page called the browser Clipboard API directly, which only exists in a secure context (HTTPS or `localhost`); on a plain-HTTP LAN address the button threw. It now uses the shared clipboard helper (with an `execCommand` fallback) and only shows "Copied" on success.
- **Presigned URLs are correct in AWS S3 mode** — with `S3_STORAGE=s3`, a configured `S3_PUBLIC_ENDPOINT` (a MinIO/dev-only concept) would override the AWS host and point presigned upload/download URLs at the wrong endpoint. AWS mode now always uses native presigned URLs and ignores `S3_PUBLIC_ENDPOINT`.
- **Public share links are usable on phones** — the comment panel defaulted to open at every screen size and was a fixed column (360px / 320px) that refused to shrink, so opening a share link on a phone showed the panel first with the main content squeezed to a sliver — and tapping the toggle to leave a comment did it again. Below the `md` breakpoint (768px) the panel now starts closed and opens as a full-width sheet over the content instead of competing for width; at `md` and above the side-by-side layout is unchanged. Applies to the single-asset viewer, folder asset viewer, and folder grid view.
- **Garage and other self-hosted S3 backends work out of the box** — non-AWS mode (`S3_STORAGE` ≠ `s3`) now forces **path-style addressing** and **SigV4** on both the server-side and presign S3 clients. Without this, boto3 could emit virtual-host-style URLs (which fail when the store sits behind a reverse proxy without wildcard bucket DNS) and SigV2 presigned URLs (which Garage rejects with `Received an unknown query parameter: 'AWSAccessKeyId'`). AWS mode is unchanged.
- **Startup bucket CORS is applied as one rule per origin** — both allowed origins used to share a single CORS rule; Garage answers such a rule by joining all its `AllowedOrigins` into one comma-separated `Access-Control-Allow-Origin` header, which browsers hard-reject, so every cross-origin request (HLS segment fetches, presigned uploads) failed CORS with e.g. `HLS error: networkError`. Per-origin rules behave identically on AWS-style backends, which echo only the matching origin either way.
- **Presigned URLs use SigV4 on AWS S3 in `us-east-1`** — `us-east-1` is the only region whose endpoint metadata still advertises SigV2, so with no explicit signature version botocore silently downgraded *presigned* URLs (uploads and playback) to SigV2 there, while server-side calls and the reported client config both still looked like SigV4. SigV2 presigned PUTs fail as soon as the browser sends a `Content-Type`, and buckets created after June 2020 reject SigV2 outright — so an operator on the default `S3_STORAGE=s3` + `S3_REGION=us-east-1` could see uploads fail with an opaque 403. SigV4 is now pinned in every mode; other regions were already unaffected.
- **Profile avatar uploads work when `S3_PUBLIC_ENDPOINT` is set** — the upload URL was presigned against the *internal* S3 endpoint, so on the usual MinIO/reverse-proxy setup the browser could not reach it. Avatars are now also stored as an S3 key instead of a long-lived presigned URL (a persisted URL started returning 403 once it expired), with a fresh short-lived URL generated per response, and the confirm step rejects keys outside the caller's own avatar prefix.
## [1.6.0] - 2026-07-14
### Added
- **Version compare** — a fullscreen split view for any two versions of an asset: synced side-by-side playback with per-side frame-accurate offset trim for video, wipe slider + side-by-side with shared zoom/pan for images, and a version-scoped comment panel on each side (comments land on the correct version at the correct frame). Deep-linkable via URL. Per-side audio control keeps one side audible at a time; each side's comments appear as markers with hover previews on the shared scrubber, and clicking one seeks to the frame, opens that side's panel with the comment focused, and shows the comment's drawing over that pane (video, side-by-side, and wipe). You can also **draw new annotations** while comparing — the pencil in either side's composer draws over that pane (video and image side-by-side; one side at a time), and the markup attaches to that version's comment.
### Fixed
- **Faster comment saving** — posting a comment no longer blocks on a full re-fetch of every comment on the asset; the new comment appears immediately (optimistic insert) while the list reconciles in the background. The comment-list endpoints (both the signed-in review view and public share links) also no longer issue a per-comment query cascade (N+1) — each now builds the whole thread in a fixed handful of queries — so loading and saving stay fast as a thread grows.
- **Settings is now reachable for non-admin users** — the sidebar "Settings" menu item and the `/settings` index both routed everyone to the admin-only `/settings/admin`, which bounced normal users back to the home page. Non-admins now land on Appearance (their first accessible settings page); superadmins still land on the admin dashboard.
- **Comments made at 0:00 keep their timecode** — the composer silently dropped the timecode when the playhead sat at the very start of a video/audio file (the badge showed `00:00:00:00` but the comment saved without it, sinking to the bottom of the timeline-ordered panel); drawings on video now always carry the frame's timecode, even with the clock toggle detached. Comment sort labels are now honest: the default is labeled "Timecode" (behavior unchanged) and "Oldest" now genuinely sorts by creation time.
## [1.5.0] - 2026-07-13
### Upgrade notes
- **Run the media-metadata backfill once after upgrading** — `docker exec freeframe-api-1 python -m apps.api.scripts.backfill_media_metadata` populates duration/resolution/fps for files uploaded before this release. Without it, older videos will ask for a frame rate when exporting comments to the NLE formats (the export still works — you just pick the fps by hand).
### Added
- **Export comments as NLE timeline markers** ([#84](https://github.com/Techiebutler/freeframe/issues/84)) — `GET /assets/{id}/comments/export?format=edl|fcpxml|premiere_xml|csv` turns a version's timecoded comments into importable markers for DaVinci Resolve (marker EDL — import via Timelines → Import → Timeline Markers from EDL, matching the timeline start TC, default 01:00:00:00), Final Cut Pro (FCPXML), and Premiere Pro (FCP7 XML — Premiere cannot import FCPXML), plus CSV. Uses the stored frame rate (see #124) with a `?fps=` override. Note: variable-frame-rate sources may land markers ±1 frame.
- **Export comments menu in the review panel** ([#84](https://github.com/Techiebutler/freeframe/issues/84)) — download markers for Resolve/Final Cut/Premiere/CSV from the comment panel toolbar; prompts for frame rate when the video predates the metadata backfill.
### Fixed
- **Transcode pipeline now persists media metadata** ([#124](https://github.com/Techiebutler/freeframe/issues/124)) — `duration_seconds`, `width`, `height`, and `fps` are stored on every new video/audio transcode (previously always NULL, breaking duration display). Existing files: run the one-off backfill — `docker exec freeframe-api-1 python -m apps.api.scripts.backfill_media_metadata`.
## [1.4.1] - 2026-07-09
### Fixed
- **Celery workers no longer report `unhealthy`** — the prod image no longer bakes an API-only `HEALTHCHECK` (`curl :8000/health`); it now lives on the `api` service in `docker-compose.prod.yml`, so `worker`/`beat`/`email_worker` (which don't serve HTTP) report their real state instead of perpetually unhealthy.
- **A slow or unreachable object store no longer blocks app startup** — the startup S3 bucket check now runs off the request path (background thread) with bounded timeouts and never crashes/hangs the app (previously a slow S3 could block startup ~60s+). It retries a transient failure with backoff, so a store that comes up shortly after start self-heals without a manual restart; a persistent failure logs a clear warning.
- **Email is documented as required for login, with a startup warning** — FreeFrame signs users in via emailed magic codes, so without a working mailer nobody can log in. The app now logs a clear warning at startup when email isn't configured, and `docs/deployment.md` + `.env.example` call it out.
## [1.4.0] - 2026-07-09
### Upgrade notes
- **S3 misconfiguration now fails fast.** If you set `S3_STORAGE=s3` (native AWS) together with a non-AWS `S3_ENDPOINT`, the app refuses to start with a clear error — use `S3_STORAGE=minio` (or any non-`s3` value) for R2/B2/Spaces/MinIO. Valid setups are unaffected.
- **boto3 1.35 → 1.43 + older S3-compatible backends.** boto3 now sends CRC32 checksums on batch deletes; MinIO/Ceph older than ~2025 reject them. `delete_prefix` falls back to per-key deletes automatically and the bundled dev MinIO is bumped — but for the fast path, upgrade an external older store.
- **Browser uploads need bucket CORS `ExposeHeaders: ["ETag"]`** (especially Hetzner, where CORS is API/CLI-only). See `docs/deployment.md`.
- **Release channels:** production self-hosters should now `git clone -b stable` instead of `main`.
### Added
- **`stable` / `latest` release channels** ([#140](https://github.com/Techiebutler/freeframe/pull/140)) — moving branch pointers on top of the immutable `vX.Y.Z` tags. `stable` = last validated release (production default), `latest` = newest release; a bad release is never promoted. See the README "Release channels" table and `docs/RELEASING.md`.
### Changed
- **Faster review playback for long videos** ([#132](https://github.com/Techiebutler/freeframe/issues/132)) — the HLS proxy no longer rebuilds a boto3 client per segment when rewriting a VOD manifest (previously ~7s of blocking overhead for a ~55-minute video); clients are now cached.
- **Dependency updates** — FastAPI 0.115 → 0.139, boto3 1.35 → 1.43, python-multipart 0.0.12 → 0.0.32, kombu 5.4 → 5.6, hls.js 1.6.15 → 1.6.16, wavesurfer.js 7.12.5 → 7.12.10 (plus dev/CI: vitest, pytest-asyncio, actions/checkout v7). The frontend now uses pnpm as the single lockfile.
### Fixed
- **S3 prefix cleanup stays compatible with older S3-compatible storage** ([#97](https://github.com/Techiebutler/freeframe/pull/97)) — boto3/botocore ≥ 1.36 send a CRC32 data-integrity checksum on batch `DeleteObjects` instead of the legacy `Content-MD5` header; S3-compatible backends predating AWS flexible checksums (MinIO older than ~2025, older Ceph/RGW, etc.) reject it with `MissingContentMD5`, which would break HLS/prefix cleanup (`delete_prefix`). `delete_prefix` now falls back to per-key deletes when a backend rejects the batch delete, and the bundled dev MinIO image is bumped to a release that supports the new checksums. `put_object`/multipart uploads are unaffected.
- **Misconfigured S3 storage fails fast instead of silently routing to AWS** ([#137](https://github.com/Techiebutler/freeframe/pull/137)) — with `S3_STORAGE=s3` (native AWS), `S3_ENDPOINT` is ignored, so pairing it with a non-AWS endpoint (Cloudflare R2, Backblaze B2, self-hosted MinIO, …) used to silently send traffic to AWS. Startup now raises a clear error naming the offending endpoint and pointing to `S3_STORAGE=minio`.
- **Browser multipart uploads: required bucket CORS `ETag` documented + surfaced** ([#131](https://github.com/Techiebutler/freeframe/issues/131)) — uploads read each part's `ETag` response header, which browsers expose only when the bucket CORS `ExposeHeaders` includes it; documented in `docs/deployment.md` (with Hetzner/AWS notes), and the previously-silent `put_bucket_cors` failure now logs a warning.
## [1.3.1] - 2026-07-08
### Added
- **Version-aware public share player** ([#120](https://github.com/Techiebutler/freeframe/issues/120)) — on folder/project/multi-share links with **Show all versions** enabled, the shared asset viewer now shows a version switcher; selecting a version swaps the streamed media and scopes comments to that version. Previously only the latest version played and comments from every version were shown regardless of the selection. The folder/grid preview (which has no version picker) now scopes its comment list — and each asset card's comment count — to the latest ready version instead of counting/showing every version's comments. New guest endpoint `GET /share/{token}/assets/{asset_id}/versions` (exposes all ready versions only when the link enables version history, otherwise just the latest); `GET /share/{token}/stream/{asset_id}` and `GET /share/{token}/comments` now accept an optional `version_id` (comments also accept `latest_only`). The separate single-asset custom-player path is tracked in [#123](https://github.com/Techiebutler/freeframe/issues/123).
- **Share preview cards show a version-count badge and duration chip** — each asset card in the folder/grid share preview now shows a "⧉ N" badge when the asset has multiple ready versions, and the multi-share preview path now passes through media duration and file size (the duration chip renders once media duration is populated — currently blocked by [#124](https://github.com/Techiebutler/freeframe/issues/124)).
### Fixed
- **Passphrase-protected share previews no longer show "No content yet"** ([#119](https://github.com/Techiebutler/freeframe/issues/119)) — the public `/share/{token}/assets` and `/share/{token}/thumbnail/{asset_id}` endpoints now honor the authenticated link creator's passphrase bypass (matching `/share/{token}/stream/{asset_id}`), so the dashboard settings preview loads a password-protected link's assets instead of rendering an empty state.
- **Video version switcher now plays the selected version's stream** ([#66](https://github.com/Techiebutler/freeframe/issues/66)) — the review player fetched `/assets/{id}/stream` without a `version_id`, so switching versions updated the dropdown but the `<video>` kept playing the latest version's stream. The player now pins the stream to the selected version and re-fetches (resetting playback) when the version changes.
- **New asset versions appear without a hard refresh, with an in-progress indicator** ([#118](https://github.com/Techiebutler/freeframe/issues/118)) — the review screen now revalidates the version list from transcode SSE events instead of a single best-effort timer, so a freshly uploaded version shows up and advances through uploading → processing → ready on its own. The version switcher trigger now surfaces a spinner/label while a new version is still uploading or processing (previously that status was only visible inside the dropdown).
## [1.3.0] - 2026-07-07
### Upgrade notes
New garbage-collection features for [#65](https://github.com/Techiebutler/freeframe/issues/65), all with safe defaults — nothing runs unless you run `celery beat`, and the destructive parts are opt-in:
- **Retention GC activates if you run `celery beat`.** A daily `cleanup_soft_deleted` job hard-deletes rows soft-deleted longer than `SOFT_DELETE_RETENTION_DAYS` (default `30`) and deletes their S3 objects, cascading the full project→folder→asset→version→media/comment/share tree. Set `SOFT_DELETE_RETENTION_DAYS=0` to disable.
- **The S3 orphan sweeper is off and report-only by default.** It runs only when `ORPHAN_SWEEP_GRACE_HOURS > 0`, and even then only *reports* bucket objects with no DB row — it deletes only if you also set `ORPHAN_SWEEP_DELETE=true`. Review its report-only logs before enabling deletion.
- **No migration required** — the GC reuses the existing `deleted_at` columns.
- **New optional env vars, all safe-by-default:** `SOFT_DELETE_RETENTION_DAYS=30`, `ORPHAN_SWEEP_GRACE_HOURS=0` (disabled), `ORPHAN_SWEEP_DELETE=false` (report-only).
### Added
- **Retention-window garbage collection** ([#65](https://github.com/Techiebutler/freeframe/issues/65)) — a daily `cleanup_soft_deleted` job hard-deletes rows soft-deleted longer than `SOFT_DELETE_RETENTION_DAYS` (default 30, `0` disables) and reclaims their S3 objects, cascading through projects, folders, assets, versions, media, comments, approvals, and share links. Long-expired share links are swept into soft-delete first. No effect unless you run `celery beat`.
- **S3 orphan sweeper** ([#65](https://github.com/Techiebutler/freeframe/issues/65)) — an opt-in weekly `sweep_orphan_s3` job reclaims bucket objects under `raw/`/`processed/` that no `MediaFile` row references. **Off and report-only by default**: set `ORPHAN_SWEEP_GRACE_HOURS` > 0 to enable (only keys older than that window are considered, so active uploads are never touched) and `ORPHAN_SWEEP_DELETE=true` to actually delete (otherwise it just logs what it would reclaim). No effect unless you run `celery beat`.
- **Manual `POST /admin/purge` endpoint** — superadmin-only; triggers the retention collector to run in the background (returns `202`); reclaimed counts are logged by the worker.
### Changed
- **`POST /assets/{id}/restore` and `/folders/{id}/restore` now return `409`** when the item's project has been deleted — a deleted project has no restore path, so there is nothing to restore into.
## [1.2.0] - 2026-07-07
### Upgrade notes
Upgrading from v1.1.6 is non-breaking by default (the new storage cap and per-file limit both default to unlimited), but note:
- **Run `alembic upgrade head`.** This adds the `instance_settings` table and widens `MediaFile.file_size_bytes` / `CommentAttachment.file_size_bytes` to `BigInteger`. ⚠️ The bigint change **rewrites the `media_files` and `comment_attachments` tables under an `ACCESS EXCLUSIVE` lock** (int4→int8 is not an in-place change in PostgreSQL), blocking reads and writes for the duration of the rewrite. Negligible on small installs; on a large `media_files` table, **run it during a low-traffic maintenance window.**
- **The upload reaper activates if you run `celery beat`.** An hourly job aborts stale, still-open S3 multipart uploads and soft-deletes `uploading`/`failed` versions older than `STALE_UPLOAD_TIMEOUT_HOURS` (default `24`), deleting their S3 objects. Raise the value to be more conservative, or set it to `0` to disable. No effect if you don't run `celery beat`.
- **New optional env vars, both with safe defaults:** `MAX_UPLOAD_BYTES=0` (unlimited per-file size) and `STALE_UPLOAD_TIMEOUT_HOURS=24`.
- **No behavior change until you opt in** — set an instance storage cap via the admin **Instance settings** tab (or `PUT /instance/settings`) when you want to enforce one.
### Added
- **Storage cap admin UI + sidebar indicator** ([#102](https://github.com/Techiebutler/freeframe/pull/102)) — the global sidebar shows instance storage `used / limit` with a meter (amber ≥80%, red ≥90%); admins set the cap in GB (`0` = unlimited) in a new **Instance settings** sub-tab on the admin settings page. Frontend for the #98 storage cap.
- **Automatic reclamation of stuck/failed upload storage** ([#101](https://github.com/Techiebutler/freeframe/pull/101)) — a scoped slice of #65: an hourly job aborts stale, still-open S3 multipart uploads and soft-deletes `uploading`/`failed` versions older than `STALE_UPLOAD_TIMEOUT_HOURS` (default 24), reclaiming their S3 objects. Prevents unbounded storage leak from interrupted/failed uploads that the committed-only cap doesn't count.
- **Instance settings + instance-wide storage cap** ([#98](https://github.com/Techiebutler/freeframe/pull/98)) — new admin-editable `instance_settings` singleton table (the home for deployment-level settings on this single-tenant instance), with an instance-wide total-storage cap as its first setting. `GET /instance/settings` (any member) returns `storage_limit_bytes` + current `storage_used_bytes`; `PUT /instance/settings` (admin) sets the limit (`0` = unlimited). The cap is enforced at upload initiate alongside the per-file `MAX_UPLOAD_BYTES` check; usage counts committed (processing/ready), non-deleted media. Backend only — admin UI + storage indicator to follow.
- **Configurable per-file upload limit** ([#64](https://github.com/Techiebutler/freeframe/issues/64)) — new `MAX_UPLOAD_BYTES` env var caps the size of a single uploaded file (`0` = unlimited, the new default). Replaces the hardcoded 10GB ceiling that self-hosters running their own S3/MinIO had no way to change or remove. Enforced at both upload-initiation points (`POST /upload/initiate` and new-version upload) with an error that reports the configured cap instead of a hardcoded "10GB". Effective size is still structurally bounded by S3 multipart (10,000 parts × 10MB chunk ≈ 97GB).
### Fixed
- **Per-project storage figure now matches the instance cap accounting** ([#100](https://github.com/Techiebutler/freeframe/pull/100)) — the per-project storage number on the project page now counts only committed (`processing`/`ready`), non-deleted media — excluding in-progress/failed/deleted uploads — consistent with the instance-wide storage cap (#98).
- **Files larger than ~2.1 GB could not be recorded** ([#99](https://github.com/Techiebutler/freeframe/pull/99)) — `MediaFile.file_size_bytes` and `CommentAttachment.file_size_bytes` were `INTEGER` (int4, ~2.1 GB ceiling), so a file above that size overflowed on insert despite per-file uploads being nominally unlimited ([#64]). Both columns are now `BigInteger`.
- **Misleading "Storage X / 10 GB" indicator on the project page** ([#64](https://github.com/Techiebutler/freeframe/issues/64)) — the project sidebar rendered a hardcoded 10 GB denominator with 80%/90% color warnings, implying a per-project quota that never existed as a real, configurable concept. It now shows only storage used — no fake denominator, no progress bar.
---
## [1.1.6] - 2026-07-06
### Fixed
- **HLS video playback on the public share page in Chrome/Firefox** ([#68](https://github.com/Techiebutler/freeframe/issues/68)) — `ShareMediaViewer` used a plain `<video src={streamUrl}>`, which only plays HLS (`.m3u8`) natively in Safari and failed in Chrome/Firefox. It now uses hls.js (already a project dependency) for MediaSource-capable browsers, falling back to native playback for Safari and direct media files; the same pattern was applied to the audio branch. ([#76](https://github.com/Techiebutler/freeframe/pull/76))
- **Public share comments response shape** ([#67](https://github.com/Techiebutler/freeframe/issues/67), [#72](https://github.com/Techiebutler/freeframe/issues/72)) — `GET /share/{token}/comments` now returns a consistent bare array on the no-target fallback path, and the single-asset share page handles the response robustly (aligned with the folder share viewer). Adds backend regression coverage for asset-share, folder/project-share, and no-target fallback paths. ([#70](https://github.com/Techiebutler/freeframe/pull/70), [#73](https://github.com/Techiebutler/freeframe/pull/73))
### Dependencies
- Bump `redis` (apps/api) from 5.1.0 to 5.3.1 ([#30](https://github.com/Techiebutler/freeframe/pull/30))
- Bump `pnpm/action-setup` from 5 to 6 (CI) ([#58](https://github.com/Techiebutler/freeframe/pull/58))
---
## [1.1.5] - 2026-04-14
### Security
- **HLS video streams now route through the API proxy so S3 objects can stay private** ([#51](https://github.com/Techiebutler/freeframe/issues/51)) — the `/stream/hls/{path}` proxy router was already built and registered in `main.py` but was never actually called. `GET /assets/{id}/stream`, `GET /share/{token}`, and `GET /share/{token}/stream/{asset_id}` all previously handed out a direct presigned URL to `master.m3u8`, which forced the HLS player to fetch variant playlists and `.ts` segments as unsigned requests — only working on buckets with public-read ACL. Non-AWS providers (Exoscale SOS, Cloudflare R2, etc.) do not inherit bucket-level ACL on new objects, so processed files returned 403 Forbidden. The three stream endpoints now mint a short-lived HLS JWT scoped to the asset's S3 prefix and return `/stream/hls/master.m3u8?token=…`; the proxy rewrites variant playlist URLs to stay inside the proxy (with the same token) and rewrites segment URLs to freshly-presigned S3 URLs. Result: the bucket can stay fully private on every S3-compatible provider, captured segment URLs expire in 24h instead of living forever via public-read, and a leaked master URL can't be replayed after its token expires. Token and segment presign TTLs both bumped from 4h to 24h so pause-and-resume works without refresh logic. Includes regression tests for the assets, share asset detail, and share stream endpoints.
---
## [1.1.4] - 2026-04-13
### Fixed
- **Share endpoint returned folder path instead of master.m3u8 for video stream URLs** ([#45](https://github.com/Techiebutler/freeframe/issues/45)) — `GET /share/{token}` was building video stream URLs from `MediaFile.s3_key_processed` (the HLS folder prefix) without appending `/master.m3u8`, so share viewers received a folder URL instead of the playlist. Mirrors the existing fix already applied in `get_share_stream_url` and `assets.py`. Includes regression tests for both the video and image paths.
- **Dashboard crash on upload with relative `NEXT_PUBLIC_API_URL`** ([#46](https://github.com/Techiebutler/freeframe/issues/46)) — `useSSE` called `new URL(`${API_URL}/events/${projectId}`)` without a base. When `NEXT_PUBLIC_API_URL` was set to a relative path like `/api` (typical for nginx-proxied deployments), the URL constructor threw `TypeError: Failed to construct 'URL': Invalid URL` the moment `UploadSSEBridge` opened its first SSE connection — crashing the dashboard immediately after any upload. Now passes `window.location.origin` as the base URL so relative paths resolve. Includes a regression test.
---
## [1.1.3] - 2026-04-11
### Fixed
- **Missing file extensions on download** ([#41](https://github.com/Techiebutler/freeframe/issues/41)) — downloaded assets were saving without an extension (e.g. `Video_Title` instead of `Video_Title.mp4`). The API now derives the extension from `MediaFile.original_filename` (authoritative) or the S3 key and appends it to `asset.name` when missing, for both `/assets/{id}/stream` and `/share/{token}/stream/{asset_id}`. The dashboard Download button now uses `?download=true` + a hidden iframe, and the share viewer no longer overrides `a.download`, so the browser honors the server's `Content-Disposition` filename.
---
## [1.1.2] - 2026-04-10
### Fixed
- **Asset downloads** ([#35](https://github.com/Techiebutler/freeframe/issues/35)) — download buttons were serving HLS `.m3u8` playlist files instead of the original media. Stream endpoints now accept `?download=true` and return a presigned URL to the raw file (or the processed file for images/audio) with `Content-Disposition: attachment` so the browser saves it with the correct filename.
- **Share link "Download All"** now recursively walks the share folder tree and downloads assets from all subfolders — previously only downloaded assets at the current level.
- **Bulk download in project view** — the Download button in the bulk actions bar now appears when only folders are selected, and selecting folders recursively downloads their assets.
- **Share link download permission** — the stream endpoint now enforces `allow_download` and logs `downloaded` activity separately from `viewed_asset`.
- **Upload dialog file list** — selecting multiple files now shows a clean per-file list with individual sizes (KB/MB) instead of a single concatenated string.
- **Dev environment** — `docker-compose.dev.yml` web service bumped from `node:18-alpine` to `node:20-alpine` (required by current frontend dependencies).
---
## [1.1.1] - 2026-04-04
### Security
- **Setup guard middleware** — all API routes return 503 and frontend redirects to `/setup` until initial superadmin is created. Exempt: `/setup/*`, `/health`, `/docs`, `/share/*`. Cached after first check for zero overhead.
### Fixed
- Branch protection `lock_branch` was preventing PR merges — unlocked while keeping review requirement
---
## [1.1.0] - 2026-04-03
### Security
- **Global rate limiting** — 600 read / 300 write requests per minute per user/IP with Redis sliding window
- **Per-endpoint rate limits** on sensitive routes: magic code (5/10min), verify (10/10min), share validation (30/min), setup (3/10min)
- **Secure HLS streaming proxy** — token-authenticated manifest rewriting with directory traversal prevention
- **Cryptographic magic codes** — replaced `random.randint` with `secrets.randbelow`
- **Upload authorization hardening** — presign-part, complete, and abort endpoints now verify `created_by` ownership
- **SSE event auth** — token query param support + project membership validation (previously had no access control)
- **Share link password sessions** — 1-hour Redis sessions after password verification so users don't re-enter passwords
- **Multi-share scope enforcement** — share links only expose specifically selected items, not the entire project
- **Rate limiters fail open** — graceful degradation when Redis is unavailable (no 500 errors)
- **CI tamper guards** — minimum test count, critical file checks, and route count assertions prevent PRs that delete tests from passing
### Added
- **Multi-item share links** — select multiple assets/folders and create a single share link (`ShareLinkItem` model + `POST /projects/{id}/share/multi` endpoint)
- **Add asset to existing share link** — `POST /share/{token}/add-asset/{asset_id}` endpoint with dropdown UI in the asset viewer
- **Viewer share button redesign** — dropdown with "New Share Link" + list of existing project share links
- **Inline comment editing** — edit button in comment menu opens textarea, saves via `PATCH /comments/{id}`
- **Copy comment link** — builds URL with `?commentId=` param; opens viewer and highlights the comment
- **Guest user comment flow** — name/email prompt for non-authenticated users on share links, persisted to localStorage
- **Storage indicator** — progress bar in project sidebar showing used / 10 GB with color warnings (amber 80%+, red 90%+)
- **SSE typed events** — `event: type\ndata: payload` format enabling frontend filtering via `EventSource.addEventListener`
- **SSE connection pooling** — Redis `ConnectionPool` prevents connection exhaustion under load
- **Non-blocking Celery dispatch** — background daemon thread so API never blocks on broker connections
- **Token refresh deduplication** — concurrent 401s share a single refresh call, preventing logout races
- **GitHub Actions CI** — 4 parallel jobs: backend tests, frontend build, lint, Docker build
- **CI tamper-proof guards** — minimum test file count (5), minimum passing tests (40), critical file existence checks, route count assertions
- **Docker build CI** — all 4 Dockerfiles (api dev/prod, web dev/prod) built and verified on every PR
- **Dependabot** — automated weekly dependency updates for pip, npm, GitHub Actions, Docker (major versions ignored)
- **Community files** — CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md, issue templates, PR template
- **GitHub Discussions** enabled
- **10 repo topics** — media-review, frame-io-alternative, self-hosted, fastapi, nextjs, etc.
### Fixed
- Share link viewer 403 errors — share token now flows through `ReviewProvider` → `ImageViewer` / `AudioPlayer` for stream URL fetching
- Password-protected share links — `share_session` threaded through all API calls (assets, stream, comments, thumbnails)
- Share link preview in project page showed all project assets instead of only shared items
- Comment author showing "User" instead of real name in share link sidebar
- Annotation drawing not working on shared assets (missing `AnnotationCanvas` render)
- Canvas annotations not scaling correctly — `_canvasWidth`/`_canvasHeight` stored in JSON for proper coordinate scaling
- Fabric.js not initializing on late-mounted canvas elements — re-bootstrap on drawing mode toggle
- Stale annotations persisting after comment submission — canvas and overlay now cleared
- Video player showing old video while new one loads — `streamUrl` reset to null on asset change
- Relative HLS proxy paths not resolving — API URL prepended for `/stream/hls/` paths
- Image viewer not filling container — `w-full h-full` instead of `inline-flex`
- Stub buttons wired up: Share + Download in fields panel, Assets `+` for new folder
- Right panel toggle hidden on projects listing page (not useful there)
- Main header hidden on asset viewer page (viewer has its own top bar)
- Removed non-functional "More" button from comment panel header
- Settings menu redirects to `/settings/admin` instead of `/settings/profile`
- Existing project members filtered from "Add member" suggestions
- Sidebar overflow in collapsed mode — `overflow-hidden` + `overflow-x-hidden`
- Back to Dashboard redirects to `/projects` instead of `/`
- Project detail endpoint now calculates `storage_bytes`, `asset_count`, `member_count`
- Backend `guest_comment` activity log crash when authenticated user comments via share link
- Pre-existing test failures in `test_auth` and `test_projects` (missing mock fields)
- `playheadTime` and `seekTarget` reset on asset change in review store
- Web Dockerfiles updated to use pnpm + Node 20 (were using npm + Node 18)
- TypeScript annotation errors in test mocks (missing `preferences`, `asset_name`, etc.)
### Changed
- `review-store`: added `setIsDrawingMode()` for explicit control (not just toggle)
- Dependabot configured to skip major version bumps (manual migration only)
- Branch protection: force push disabled on main
### Dependencies Updated
- next 14.2.29 → 14.2.35
- sqlalchemy 2.0.35 → 2.0.49
- pytest 8.3.3 → 8.4.2
- python-jose 3.3.0 → 3.5.0
- email-validator 2.2.0 → 2.3.0
- psycopg2-binary 2.9.9 → 2.9.11
- jinja2 3.1.4 → 3.1.6
- wavesurfer.js 7.12.4 → 7.12.5
- vitest 4.1.0 → 4.1.2
- @types/node 22.19.15 → 22.19.17
- actions/checkout v4 → v6
- actions/setup-python v5 → v6
- actions/setup-node v4 → v6
- pnpm/action-setup v4 → v5
## [1.0.0] - 2026-03-27
Initial release — backend-only v1 with:
- FastAPI backend with JWT authentication and magic code login
- Org → Team → Project hierarchy with role-based permissions
- Asset upload (multipart S3), versioning, and media processing (FFmpeg → HLS, WebP, MP3)
- Comments with threading, timecode ranges, annotations (Fabric.js), and guest comments
- Approvals, sharing (links + direct), metadata fields, collections
- Branding, watermarks, notifications, SSE events
- Next.js 14 frontend with review interface, share viewer, admin panel