Files
amethyst/desktopApp/plans/2026-10-11-desktop-video-compression.md
davotoula 9cb6300dcf fix(uploads): count 59.94 fps as high frame rate for the video bitrate
The 1.3x multiplier required framerate >= 60, so broadcast 59.94 fps
(60000/1001) clips got the 30 fps bitrate at twice the frame count. The
floor is now 59 fps, which admits 59.94 and still excludes 50 fps. The rule
is shared, so Android changes with it.
2026-10-11 20:44:54 +01:00

398 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Desktop video compression — design
**Status:** approved design (revised after review 2026-10-11), awaiting implementation
**Branch:** `feat/desktop-video-compression`
**Scope:** phase 1 of two. Phase 1 makes the composer's video controls real on the JVM desktop when a
system `ffmpeg` is installed. Phase 2 (separate plan) bundles an LGPL FFmpeg per OS so nothing has to
be installed.
## Background — what the reports are about
Users reported "too aggressive" video compression from Amethyst Desktop. The investigation
(2026-10-11) showed the opposite: the JVM desktop has **never compressed video**.
- `DesktopMediaUploader.compressIfNeeded` returns its input unchanged; `useH265` is accepted and
never read.
- The upload pipeline (`commons/jvmMain/…/upload/UploadOrchestrator.kt`) runs `ImageReencoder`,
whose sniffer classifies a video as `NotAnImage` → `PassThrough` → original bytes.
- `jcodec` and the FFmpeg subprocess exist only for `VideoThumbnailCache`; no video encoder is
wired into any upload path, on this branch or on `upstream/main`.
- A 1080p test clip uploaded from desktop came back byte-identical (same sha256, 2 247 200 bytes)
from blossom.band, blossom.primal.net, nostr.download and cdn.nostrcheck.me.
Yet the shared composer shows a **media-quality slider** and a **"Use H.265/HEVC Codec"** switch for
any video, on every platform, and both are ignored on desktop. People upload a low-bitrate source
(phone export, downloaded clip, Android's own 720p @ 2 Mbps output), judge the result against the
controls they just set, and attribute the source's quality to the client. Two further desktop bugs
fall out of the same gap:
- `ImageVideoDescription` hides the strip-metadata toggle for a video whenever the slider is not
UNCOMPRESSED, on the promise that compression strips metadata. On desktop nothing strips it
(the JVM stripper, `MediaCompressor.stripExif`, handles JPEG only).
- The `convertGifToMp4` switch is equally dead on desktop.
Android compresses video with `LightCompressor-enhanced` (MediaCodec) using a tuned table in
`amethyst/…/service/uploads/VideoCompressionHelper.kt`. Those settings are the reference; desktop
copies them.
## Goals
1. On desktop, the slider and the H.265 switch produce output comparable to Android's for the same
level: a 1080p clip at HIGH → 1080p H.264 @ 4 Mbps; with H.265 → HEVC @ ×0.75.
2. The UI never claims a capability the platform lacks. Without `ffmpeg`, video controls are hidden
and replaced by a one-line hint; the original uploads as today. When the dialog hides the
strip-metadata toggle because "compression strips", the bytes that leave the machine have no
metadata — including when compression falls back to the original.
3. One source of truth for the resolution/bitrate table, shared by Android and desktop, so the
platforms cannot drift.
4. No new third-party dependency and no change to Amethyst's MIT licensing position.
5. Phase 1 code must work unchanged with a phase-2 bundled LGPL binary that has no `libx264`.
## Non-goals (phase 1)
- Bundling FFmpeg (phase 2).
- HLS publishing on desktop.
- `amy` CLI flags for video quality.
- A VERY_HIGH / VERY_LOW row for video. Those levels exist for images only; for video they map to
HIGH / LOW (see §1).
- Two separate sliders for images and videos (considered and rejected: a second quality value would
have to be threaded through every composer ViewModel and `MultiOrchestrator.upload` on Android
too, to serve the rare mixed photo+video post).
- HDR tone-mapping. Phase 1 detects HDR and warns (§2); converting HLG/PQ to SDR properly, or
keeping 10-bit HEVC, is a follow-up.
- Reading the real frame rate on Android. Android reads `METADATA_KEY_CAPTURE_FRAMERATE`, which
ordinary clips do not carry, so it almost always assumes 30 fps and never applies the ×1.3
multiplier. Desktop reads the real average frame rate (§2), so a 1080p60 clip gets 5.2 Mbps on
desktop and 4 Mbps on Android. Fixing Android to read the real rate is a follow-up; the table and
multipliers are still identical.
- The Marmot group-icon and Cordn chat callers of `compressIfNeeded` pass *images* through untouched
on desktop today (the icon uploads at UNCOMPRESSED). Videos through those callers are covered by
this design; the image behaviour is pre-existing and out of scope.
## Decisions taken during brainstorming and review
| Question | Decision |
| --- | --- |
| Encoder delivery | Phase 1: system `ffmpeg` found on `$PATH` or in the usual package-manager locations, or already present in `appResources/<os>/ffmpeg/`. Phase 2: bundled LGPL binary. |
| No `ffmpeg` installed | Degrade quietly: hide video controls, show hint, upload original. No dialog, no failure. |
| Slider model | One slider. When the selection contains a video, the displayed stops are the platform list filtered to levels that have a video rule (desktop: LOW/MEDIUM/HIGH/UNCOMPRESSED — the Android layout). |
| Licensing | A user-installed GPL `ffmpeg` is invoked as a separate process over argv and pipes, not linked, and nothing is distributed: no licensing impact. Phase-2 bundled binary must be LGPL (no x264/x265), hence the encoder preference list below, plus the LGPL duties in §6. |
| VAAPI | Not in the preference list. `*_vaapi` needs `-vaapi_device` and a `hwupload` filter; a one-size command line would only add a guaranteed failed attempt. Linux falls through to `nvenc`/`qsv`/`amf` or `libopenh264`. |
| HDR | Detect and warn, encode as designed; tone-mapping deferred. |
| Label | Describes the picked file's real resolution (desktop probes it), not a fixed "1080p" example. |
## Design
### 1. Shared data — the rules table moves to `commons`
`VideoStandard`, `VideoResolution` (+ `getStandard()`), `VideoInfo`, `CompressionRule`
(+ `getBitrateBps()`) and the LOW/MEDIUM/HIGH table move **verbatim** from
`amethyst/…/VideoCompressionHelper.kt` to
```
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/video/VideoCompressionRules.kt
```
Android imports them from there and its copies are deleted. Unchanged semantics:
- the table (short side keyed by source standard, bitrate in Mbps), **including its UNKNOWN rows**
(LOW 480p/0.75, MEDIUM 720p/2, HIGH 1080p/4), which apply when the probe succeeds but the
resolution is below 240p;
- ×1.3 at ≥ 59 fps (so broadcast 59.94 fps counts as 60), ×0.75 for H.265;
- the **probe-failure fallback**, which is not a table row: when the video cannot be probed at all,
2 Mbps and no resize (`VideoCompressionRules.PROBE_FAILED_BITRATE_BPS = 2_000_000`).
Two additions in `CompressorQuality`:
- `hasVideoRule: Boolean` — true for LOW, MEDIUM, HIGH and UNCOMPRESSED.
- `videoLevel: CompressorQuality` — the level the video encoder uses: VERY_HIGH → HIGH,
VERY_LOW → LOW, everything else itself. Both the compressor and the slider go through it, so a
position kept across dialogs (`NewMediaModel`, `ChatFileUploadState` keep the slider position
across `load()`) can never reach the encoder as a level without a rule.
Nothing else in the enum changes: the Int slider position remains an index into the platform list
(`sliderSteps`), and every ViewModel keeps calling `fromSlider(Int)`. The 4-stop layout is a display
concern handled by the slider composable (§4).
### 2. Encoder — `FfmpegVideoCompressor` in `commons/jvmMain`
Lives next to the JVM upload pipeline (`commons/src/jvmMain/…/service/upload/video/`). CLI-safe, no
Compose, so `amy` can use it later. No dependency is added: FFmpeg is an external process.
**`FfmpegLocator`** — extracted from `VideoThumbnailCache`. Resolution order for `ffmpeg` and
`ffprobe` alike: (1) the bare name on `$PATH`, probed with `-version` (2 s cap, killed on overrun);
(2) the package-manager directories a Finder- or Start-menu-launched app does not have on its PATH
(`launchctl getenv PATH` is empty on macOS, so a packaged app sees only `/usr/bin:/bin:/usr/sbin:/sbin`):
`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin` on macOS/Linux; `%ProgramFiles%\ffmpeg\bin`,
`%LOCALAPPDATA%\Microsoft\WinGet\Links`, `%USERPROFILE%\scoop\shims`, `%ChocolateyInstall%\bin` on
Windows; (3) `${compose.application.resources.dir}/ffmpeg/<name>(.exe)`. Never a
working-directory-relative path. The result is cached for the process lifetime and **warmed on an
IO thread at app start**, so reading availability from composition never blocks on the probe.
The environment variable `AMETHYST_FFMPEG_DISABLED` (any value) makes the locator report nothing,
so the app and the gated tests can be run as a machine without ffmpeg — a bare `PATH` is not
enough, because the locator finds Homebrew by design. `VideoThumbnailCache` switches to it; phase
2 only has to put binaries in the resources folder.
**`FfmpegEncoders`** — runs `ffmpeg -hide_banner -encoders` once and picks by preference:
| Codec | Preference order |
| --- | --- |
| H.264 | `libx264` › `h264_videotoolbox` › `h264_nvenc` › `h264_qsv` › `h264_amf` › `h264_mf` › `libopenh264` |
| H.265 | `libx265` › `hevc_videotoolbox` › `hevc_nvenc` › `hevc_qsv` › `hevc_amf` › `hevc_mf` |
The candidate list for a request is the available HEVC encoders in order (when H.265 was asked for)
**followed by** the available H.264 encoders in order. A listed hardware encoder can still fail at run
time (no GPU, no driver, headless session, NVENC session limit), so on a non-zero exit the next
candidate is tried; the bitrate and the `hvc1` tag are recomputed per candidate because the codec
can change along the way. H.265 requested but the encode ends on an H.264 candidate → a
`UserNotice`. This is what keeps phase-1 code valid for a phase-2 LGPL binary that has no x264.
**Probe** — `ffprobe -v error -select_streams V:0 -show_entries
stream=width,height,avg_frame_rate,color_transfer -show_entries format=duration -of json`.
`V:0` skips attached cover art. `avg_frame_rate` rather than `r_frame_rate`: phone clips are
variable-frame-rate and `r_frame_rate` reports the timebase, not the rate. Width/height are
pre-rotation, but the table keys on the short side, so orientation does not matter. `ffprobe`
missing or failing → the probe-failure fallback of §1. `color_transfer` of `smpte2084` or
`arib-std-b67` marks the clip HDR → a `UserNotice` ("HDR video looks flat after compression; choose
Uncompressed to keep it") and the encode proceeds.
**Command** (one invocation; `in` is the picked file, `out` a temp `.mp4` under `AmethystTempDir`):
```
ffmpeg -hide_banner -loglevel error -nostdin -y
-i <in>
-map 0:v:0 -map 0:a:0? -sn -dn -map_chapters -1
-c:v <encoder> -b:v <bps> -maxrate <bps> -bufsize <2·bps> -pix_fmt yuv420p
-vf "scale=<expr>" # short side ≤ rule.shortSide, never upscale, even dimensions
-c:a aac -b:a 128k
-map_metadata -1 -map_metadata:s -1
-movflags +faststart
[-tag:v hvc1] # HEVC only, so Apple players accept it
-progress pipe:1
<out>
```
Output is always `video/mp4`. `+faststart` mirrors Android's `isStreamable = true`. Metadata,
chapters, subtitle and data streams are dropped on purpose: the composer hides the strip-metadata
toggle on the promise that compression strips, and this makes the promise true on desktop.
(Verified on ffmpeg 9.0.2: both `-map_metadata` forms are needed; the scale expression turns a
rotated 1920×1080 clip into 720×1280 with no rotation matrix left; `-c:a aac` is fine with no audio,
Opus or PCM sources; `-progress pipe:1` works with `-loglevel error`.)
**The original must be stripped too.** Whenever ffmpeg is present and the pipeline ends up
uploading the original video — output ≥ original, every encoder failed, or the dialog's own
`stripMetadata` toggle for a video at UNCOMPRESSED — the bytes are first remuxed with
```
ffmpeg -hide_banner -loglevel error -nostdin -y -i <in>
-map 0:v -map 0:a? -dn -sn -c copy
-map_metadata -1 -map_metadata:s -1 -map_chapters -1 -movflags +faststart
-progress pipe:1 <out>
```
(seconds, no re-encode). The stream maps are deliberate: `-map 0 -c copy` fails (exit 234,
"Could not find tag for codec none") on a MOV that carries a timecode or other data track —
which is what iPhones and cameras write — and that failure would upload the original with its
`location` tag while the toggle is hidden. Video and audio only, data and subtitle tracks dropped.
`-progress pipe:1` is on the remux too, so the stall watchdog below sees output and does not kill
a legitimate long remux. The same remux backs `MediaUploader.stripMetadata()` for `video/*` on
desktop, so the chat dialogs that keep the toggle visible strip too. Without ffmpeg nothing can
strip a video; the toggle then stays visible and its "could not strip" path applies, as today.
(Verified on ffmpeg 9.0.2 with a `testsrc` MOV built with `-timecode 00:00:00:00 -metadata
location=… -metadata title=secret`: the old form exits 234, the new one strips both tags.)
**Progress and cancellation** — the `out_time_us=` lines from `-progress pipe:1` (`N/A` tolerated)
divided by the probed duration drive `UploadingState.Compressing` from 0.02 up to the level where
the upload stage starts (0.20 for Blossom/NIP-96, 0.40 for NIP-95, so progress never jumps
backwards). Coroutine cancellation → `process.destroyForcibly()`, temp deleted under
`NonCancellable`. No fixed overall timeout (Android's 60 s is a known weakness for long clips);
instead a **stall watchdog**: no progress line for 60 s → kill and treat as failure. Uploads run in
`viewModelScope` and the JVM may exit mid-encode, so every live process is held in a registry that
a **JVM shutdown hook** kills — no orphaned `ffmpeg` after the window closes. Only the two pieces
that block on a child process (the process runner and the probe) switch to `Dispatchers.IO`; the
compressor itself stays on the caller's dispatcher, so its cancellation and temp-file behaviour is
testable under `runTest` virtual time. Temp files come from an injectable factory (default
`AmethystTempDir.createTempFile("amethyst_video_", ".mp4")`) so tests never touch the real
`~/.amethyst/tmp` of a running app.
**Concurrency** — `MultiOrchestrator` uploads every item in parallel; N videos would start N
`ffmpeg` processes each using every core, and hardware encoders have session limits. A
process-wide `Semaphore(1)` serialises encodes (the pattern `ImageReencoder` already uses).
**NIP-95** — the relay-event limit is 80 000 bytes. Before encoding, `(bitrate + 128 kbps) × duration`
is estimated from the probe; over the limit → `MEDIA_TOO_BIG_FOR_NIP95` at once, without a
multi-minute encode that would be refused afterwards.
**Result rules**
- Output size ≥ original → discard output, use the (remuxed, stripped) original.
- `ffmpeg` found but every candidate fails → upload the (remuxed, stripped) original and report a
`UserNotice` ("Video compression failed; uploading the original").
- UNCOMPRESSED, or `ffmpeg` not available → the encoder is not called.
- The caller owns the temp file and deletes it when the upload finishes or fails.
**GIF → MP4** — same runner; the input is the GIF, no bitrate table: the source size is kept
(rounded down to even), exactly as Android's `GifToMp4Converter`, encoded with the H.264 candidate
list at the MEDIUM row's bitrate for the GIF's standard. Android converts even at UNCOMPRESSED when
the switch is on, so desktop does too. Included so no second dead switch survives on desktop. A
GIF conversion that fails uploads the GIF untouched (never remuxed — it is not a video) with the
"compression failed" notice.
### 3. Wiring — `DesktopMediaUploader`
- `MediaUploader` gains `val videoCompression: VideoCompressionSupport` with three values:
`Supported` (Android; desktop with ffmpeg), `NeedsFfmpeg` (desktop without), `Unsupported`
(the commonMain `UnavailableMediaUploader`, iOS). The dialog shows the install hint only for
`NeedsFfmpeg`.
- `MediaUploader` gains `suspend fun probeVideo(uri: MediaUri): VideoInfo?` (default null;
desktop delegates to the compressor's own `probe`, so the uploader has one ffmpeg dependency and
nothing resolves `ffprobe` at construction time) so the slider label can describe the picked
file (§4).
- A private `withVideoPrepared(progress, uri, mimeType, quality, useH265, stripMetadata,
convertGifToMp4, progressCeiling, maxBytes) { file, prepared -> … }` in `DesktopMediaUploader`
hands its block either the original file, a remuxed copy (when stripping is owed), or a
compressed temp, and deletes what it created in `finally`. The same decision table backs
`compressIfNeeded` (used by the Marmot group-icon and Cordn chat callers) and the four upload
entry points (`upload`, `uploadEncrypted`, `nip95`, `nip96`). For a prepared file the pipeline is
called with `quality = null` and `stripExif = false` (already stripped). Otherwise the call is
exactly what it is today.
- Encrypted (DM) uploads compress the plaintext first, then encrypt, through the same wrapper.
- **Notices.** The uploader is one per process (`DesktopAccountViewModelHost`), while
`UserNoticeSink` lives on each `AccountViewModel` (`BaseAccountViewModel.notices`). The host owns a
`ForwardingNoticeSink` (a `UserNoticeSink` with a settable target); `DesktopAppRoot.createAccountViewModel`
points it at the new view model's `ToastManager`. The uploader is built with that sink. New
notice types are a `sealed interface UploadNotice : UserNotice` in `commons/notices`
(`VideoCompressionFailed(reason)`, `HevcUnavailable`, `HdrSource`), worded in
`commonsUI/notices/ui/UserNoticeText.kt`.
- `VideoThumbnailCache` switches to `FfmpegLocator`. No behaviour change.
### 4. Dialog — `commonsUI` (shared, so Android benefits)
`ImageVideoDescription.kt`, `NewMediaView.kt` and `ChatFileUploadDialog.kt`:
- **Slider** (`MediaQualitySlider`): the slider is in *video mode* when the selection has a video
**and** `videoCompression == Supported` — on a platform that will not compress the video, the
slider is only about the images, so it keeps the image stops and says nothing about video. In
video mode the displayed stops are `sliderSteps.filter { it.hasVideoRule }`. Desktop shows
LOW/MEDIUM/HIGH/UNCOMPRESSED; Android and iOS are unchanged (their lists already have only
those). The composable maps displayed index ↔ platform position internally; the state stays a
platform position. Entering video mode while the position is on a level without a video rule
snaps to `videoLevel` (VERY_HIGH → HIGH). Known and accepted: in a mixed photo+video post this
also lowers the photos from 2560 px to 1920 px, and removing the video again does not restore
VERY_HIGH.
- **Label** is content-aware: `High · images up to 1920 px`, `High · 4K video → 2160p, 8 Mbps`, or
both for a mixed post. Every word including the `p` and `Mbps` units comes from string resources
with positional arguments (`%1$s video → %2$dp, %3$s Mbps`); the code supplies the source
standard's label, the rule's short side and the bitrate (`2`, `0.75`, no trailing `.0`). The
video phrase uses `probeVideo()` on the first video (desktop) and the rule for *its* standard;
where the probe returns null (Android, iOS, a corrupt file) the phrase describes the 1080p row
with a "1080p video →" prefix so it is never presented as the file's own.
- **Desktop without `ffmpeg`** (`videoCompression == NeedsFfmpeg`): the H.265 and GIF→MP4
switches are hidden; the slider is shown only when the selection has a compressible *image*
(`MultiOrchestrator.hasCompressible()` counts videos, so a new `hasCompressibleImage()` is used);
one hint line "Install ffmpeg to compress videos" (new string `video_compression_needs_ffmpeg`).
For `Unsupported` the same controls are hidden and no hint is shown.
- **Bug fix:** `isVideoWithCompression = hasVideo && videoCompression == Supported &&
slider != UNCOMPRESSED`. Today the strip-metadata toggle disappears on desktop for a video that
is then uploaded with its metadata intact.
- **The three dialogs differ** and keep their differences; only the conditions change:
| Dialog | Slider | H.265 switch | GIF→MP4 switch | Strip-metadata toggle |
| --- | --- | --- | --- | --- |
| `ImageVideoDescription` (post composer) | compressible image, or video when `Supported`; hidden while GIF→MP4 is on | `isVideoWithCompression` | `hasGif() && Supported` | hidden when `isVideoWithCompression` |
| `NewMediaView` (media post) | always shown today; stays | `isVideoWithCompression` | none (no state for it) | always shown |
| `ChatFileUploadDialog` | compressible image, or video when `Supported` | none (`ChatFileUploadState` has no `useH265`) | none | always shown |
The hint line appears in all three for `NeedsFfmpeg` when the selection has a video.
- **"Upload Image" → "Upload Media"**: new string `upload_media` for every attach button whose
picker accepts photos and videos — `SelectFromGallery`, `NewGroupDMScreen.kt`,
`NewVideoFeedButton.kt`, `NewShortVideoButton.kt`, `NewLongVideoButton.kt`. `upload_image` stays
where the picker really is images only: `SelectSingleFromGallery` (profile picture, banner, group
and channel pictures) shares the private `GallerySelectButton` with `SelectFromGallery`, so the
button takes its label as a parameter instead of hard-coding one; `NewPictureButton` is unchanged.
All new strings go in `commonsUI/src/commonMain/composeResources/values/strings.xml`.
### 5. Tests
`commons/jvmTest`
- Rules table: every (level, standard) row including UNKNOWN; `getStandard()` boundaries; fps and
H.265 multipliers (59.94 fps gets the ×1.3, 50 fps does not); `videoLevel` / `hasVideoRule`; the probe-failure
constant. (No such tests exist on Android today.)
- Slider mapping: displayed index ↔ platform position, snap on adding a video, UNCOMPRESSED
position stable — written against an explicit step list passed in as a parameter, because
`platformSliderSteps` is an internal `expect` and the test must cover both the 5-stop and the
4-stop lists.
- `FfmpegCommandBuilder`: pure arg-list builder — scale expression (no upscale, even dims, portrait),
stream maps, bitrate flags, HEVC tag, metadata flags, the remux command, the GIF branch. No ffmpeg
needed.
- `-progress` parser: `out_time_us` → fraction; `N/A`; unrelated keys; `progress=end`.
- Encoder selection from canned `-encoders` output: full GPL build, LGPL-no-x264 build, HEVC
absent, HEVC listed but every HEVC candidate failing at run time → the H.264 tail is reached.
- Locator: PATH, package-manager directories, bundled, none, disabled by the environment variable.
- Runner with a fake process: candidate retry deletes the failed temp, all-fail notice, larger
output discarded (and the original remuxed), remux failure → unstripped original, GIF failure →
untouched GIF, HEVC-unavailable notice, HDR notice, probe-failure fallback, progress forwarding,
NIP-95 early refusal, the semaphore (two concurrent calls run one process at a time),
cancellation kills and deletes. Temp files are counted in a per-test directory handed to the
compressor, never in the real `~/.amethyst/tmp`.
- The real process runner, with a real child process (`sleep`/`timeout.exe` — no ffmpeg needed):
a child that prints nothing for longer than the stall timeout is killed, reports exit −1 and
leaves the registry empty; a normal exit is forwarded with its stderr tail.
- Gated on `ffmpeg` **and `ffprobe` and `libx264`** present (a build without x264 encodes
differently and the size assertions would not hold); the gate is `FfmpegLocator.default`, which
reports nothing available when the environment variable `AMETHYST_FFMPEG_DISABLED` is set — the
way to run the suite as a machine without ffmpeg, since the locator finds Homebrew even with a
bare `PATH`. Fixtures, built with the real binary into a temp dir:
- `noisy.mp4`: a high-entropy synthetic source (`-f lavfi -i
"color=c=gray:s=1920x1080:r=30,noise=alls=100:allf=t+u"`, 2 s, `-crf 18 -preset ultrafast`,
`-metadata title=secret` — `noise` is a filter, not a source, so it needs a `color` source in
front) whose encodes are *smaller* than the source, so the size rule keeps them: HIGH →
1920×1080, LOW → short side 480 (measured 854×480), no `title` tag (the container's
brand/encoder tags remain; they carry no user data); bitrate asserted only as the **VBV upper
bound** `≤ b × (1 + 2/duration)` — with `bufsize = 2b` a short clip legitimately bursts above
`b` (measured 6.07 Mbps on 1 s at a 4 Mbps target), and x264 undershoots on easy content —
plus LOW's bitrate < HIGH's; HEVC → codec `hevc` when no `HevcUnavailable` notice.
- `easy.mp4`: a low-entropy source (`testsrc`, 55 KB, also with `-metadata title=secret`)
proves the "larger than original → original, remuxed" rule (its HIGH encode measured 181 KB)
and that the remuxed original has no `title`.
- `rotated.mp4`: `-display_rotation 90 -i noisy.mp4 -c copy` (the old `-metadata:s:v rotate=90`
writes nothing on ffmpeg 9). It must derive from `noisy`, not `easy`: `easy`'s encode is
larger than the source, so the size rule would return the remux — which still carries the
rotation side data — and the test would not exercise the encoder. Comes out 1080×1920 with no
rotation side data (measured 215 KB from the 90 MB source).
- `tc.mov`: `testsrc` written as MOV with `-timecode 00:00:00:00 -metadata location=… -metadata
title=secret`, which adds a data track; `strip()` must succeed and the result has one video
stream and neither tag. (This is the fixture that fails with `-map 0`.)
`desktopApp/jvmTest`
- Uploader video path with a fake compressor: temp cleanup on success, failure and cancellation;
pass-through when not `Supported`; UNCOMPRESSED; GIF only when asked; notices forwarded;
`Compressing` progress reaches the orchestrator up to the per-path ceiling. `ForwardingNoticeSink`.
Manual (see the sheet written at the end of implementation)
- With `ffmpeg` and with `AMETHYST_FFMPEG_DISABLED=1` in the environment; **the packaged app
launched from Finder / the Start menu** (not `:desktopApp:run`, which inherits the shell PATH);
each slider stop; H.265 on a machine with
and without an HEVC encoder; GIF→MP4; a portrait phone video; an HDR iPhone clip (notice shown);
three videos in one post (one ffmpeg at a time); quit mid-encode (no `ffmpeg` left running, temp
dir clean); a corrupt video (uploads original + toast); the blossom hash check from the
investigation (the uploaded blob's sha256 must now differ from the source and `ffprobe` must show
the rule's dimensions and a bitrate at or under the target).
### 6. Phase 2 (separate plan)
Gradle task fetching a verified LGPL FFmpeg build per OS — **both `ffmpeg` and `ffprobe`**, or
every level lands on the probe-failure fallback — into `appResources/<os>/ffmpeg/` at packaging
time, plus macOS codesigning (the notes in `appResources/macos/ffmpeg/README.md`). Phase 1 needs no
change for it: the locator already looks there, and the encoder preference list already copes with
a build that has no `libx264`/`libx265`.
Licensing duties that come with bundling, to be called out in that PR per the repo's WARN rule:
LGPL requires the FFmpeg source (or a written offer) and its notices in `appResources/common/NOTICE.md`;
`libopenh264` is BSD but Cisco's H.264 patent grant covers only Cisco's own binary, not a build we
compile ourselves — prefer Cisco's binary or document the exposure.