ecompress
Compress any file below a target size. One command, two arguments.
ecompress "D:\Videos\movie.mp4" 50
Make this file smaller than 50 MB.
That is the whole interface. You never specify a codec, a CRF, a bitrate, a quality number or a resolution — the package works those out, encodes, measures the real file on disk, and tries again until it fits.
Compressing:
D:\Videos\movie.mp4
Original size: 82.4 MB
Target size: 50 MB
Detecting media type...
Video detected (1920x1080, 124.0s, 30 fps).
Optimizing...
Attempt 1: 61.2 MB [3.85 Mbps]
Attempt 2: 47.9 MB [2.98 Mbps] <- best so far
Attempt 3: 48.9 MB [3.06 Mbps] <- best so far
Compression successful.
Original: 82.4 MB
Compressed: 48.9 MB
Saved: 33.5 MB
Reduction: 40.7%
Output:
D:\Videos\movie_compressed.mp4
Install
pip install ecompress
That is the whole install. FFmpeg comes with it — you do not install
anything separately, and nothing needs to be on your PATH. Python 3.10 or
newer.
The PyPI name, the command and the import name are all ecompress. The
function you call is still compress():
from ecompress import compress
Renamed in 2.0. This project was
compress-cli(commandcompress) up to 1.3.0. The old import name shadowed an unrelatedcompresspackage on PyPI, and the old command collided with/usr/bin/compress, the classic Unix.Ztool, on Linux and macOS. Nothing about how it works changed — only the names.
Check what landed on your machine at any time:
ecompress --check
ecompress 2.4.0
Python 3.12.10 on Windows 11 (AMD64)
Video and audio (FFmpeg)
OK ffmpeg version 6.0-essentials_build
bundled with this package
ffmpeg: ...\site-packages\ffmpeg\binaries\bin\ffmpeg.exe
ffprobe: ...\site-packages\ffmpeg\binaries\bin\ffprobe.exe
OK H.264 video (.mp4, .mkv, .mov) (libx264)
OK VP9 video (.webm) (libvpx-vp9)
OK AAC audio (.m4a) (aac)
OK MP3 audio (.mp3) (libmp3lame)
OK Opus audio (.opus) (libopus)
OK Vorbis audio (.ogg) (libvorbis)
OK FLAC audio (.flac) (flac)
Images (Pillow)
OK Pillow 11.3.0
formats: JPEG, PNG, WEBP, AVIF, GIF
PDF (pikepdf)
OK pikepdf 10.11.0
Everything is installed. All supported file types will work.
What gets installed, and where
| Component | Comes from | Installed where |
|---|---|---|
| ffmpeg + ffprobe | ffmpeg-binaries |
inside your environment's site-packages |
| Image support | pillow |
site-packages |
| PDF support | pikepdf (bundles qpdf) |
site-packages |
Everything lives inside the Python environment you installed into — a venv, a
conda env, or your user site-packages. Nothing is written to system
directories, nothing touches your PATH, and uninstalling removes all of it:
pip uninstall ecompress ffmpeg-binaries
Platforms without a prebuilt FFmpeg
Windows x64, macOS (Intel and Apple Silicon) and Linux x86_64 get FFmpeg
automatically. On Linux ARM, Alpine/musl and 32-bit Windows there is no
prebuilt wheel, so ecompress --check will report it and you have two options:
pip install "ecompress[ffmpeg]" # fetches a build for your platform
sudo apt install ffmpeg # or use your system package manager
A system FFmpeg already on PATH is picked up automatically. To point at a
specific build, set COMPRESS_FFMPEG and COMPRESS_FFPROBE.
Images and PDFs never need FFmpeg at all.
The target size is a hard limit
The second argument is the maximum size of the result in megabytes.
1 MB = 1,000,000 bytes, so ecompress "video.mp4" 50 guarantees:
actual_output_bytes < 50,000,000
This is not an estimate. Every candidate encode is measured on disk and
re-opened by an independent reader — Pillow for images, ffprobe for video and
audio, pikepdf for PDFs — before it can be accepted. A 50.1 MB result is never
reported as a success for a 50 MB request.
If the target genuinely cannot be reached, the command says so and exits with
status 1:
Error: Target size could not be achieved.
Target: 1.0 MB
Smallest valid output: 3.2 MB
Even at 256x144 and the minimum usable bitrate (24 kbps video + 32 kbps audio),
a 124.0s clip cannot fit in the requested size.
Examples
# Video
ecompress "D:\Videos\movie.mp4" 50
ecompress "clip.mkv" 8
ecompress "screen-recording.webm" 25
# Images
ecompress "D:\Photos\holiday.jpg" 5
ecompress "screenshot.png" 0.5
ecompress "banner.webp" 1
# Audio
ecompress "D:\Music\song.wav" 10
ecompress "podcast.mp3" 20
ecompress "interview.m4a" 5
# PDF
ecompress "D:\Documents\report.pdf" 2
ecompress "scan.pdf" 1.5
Fractional targets work: 0.5, 1.5, 49.9 are all valid.
While a video encodes you get a progress bar, a percentage and an estimate of the time remaining:
[#####...................] 21% 0:09 left 2560x1440 @ 24 fps
It is drawn only to a real terminal, so piping the output somewhere or using
--quiet / --json stays clean.
Setting a minimum too
A bare ceiling lets the result land anywhere below it. Give a range and the budget gets used instead of undershot:
ecompress "movie.mp4" 40-50 # below 50 MB, but not under 40 MB
40-50, [40,50], 40..50 and --min 40 all mean the same thing.
The two ends behave differently, deliberately:
- The maximum is a hard limit. The result is always strictly below it.
- The minimum is a quality floor. The search keeps raising quality until it reaches it — that is the point, since a bigger file at the same settings means fewer artefacts.
If a file genuinely cannot reach the floor — the source is already small, or the format has nothing left to give — you get the result anyway, with a note saying why. Padding a file with junk to hit a number is never done, because those bytes would add size without adding quality.
result.within_requested_range tells you whether both ends were satisfied.
Where the output goes
Next to the original, with _compressed before the extension:
D:\Videos\movie.mp4 -> D:\Videos\movie_compressed.mp4
D:\Photos\photo.jpg -> D:\Photos\photo_compressed.jpg
D:\Music\song.wav -> D:\Music\song_compressed.mp3
The original is never modified or overwritten. If
movie_compressed.mp4 already exists, the next free name is used —
movie_compressed_1.mp4, movie_compressed_2.mp4, and so on. Names are
reserved atomically, so two runs at the same time cannot collide.
The absolute output path is printed on success and is available as
result.output_path.
Already small enough?
Nothing is re-encoded and nothing is degraded:
File is already below the requested target.
Original: 20.0 MB
Target: 50 MB
No compression necessary; the original was left untouched.
No new file is created — result.output_path is the original path and
result.skipped is True.
Supported files
| Kind | Extensions | Engine |
|---|---|---|
| Image | .jpg .jpeg .png .webp .avif .gif .bmp .tif .tiff |
Pillow |
| Video | .mp4 .mkv .mov .webm .avi .m4v .wmv .flv .mpg .ts .3gp |
FFmpeg |
| Audio | .mp3 .wav .m4a .aac .flac .ogg .opus .wma .aiff |
FFmpeg |
.pdf |
pikepdf (+ Ghostscript when installed) |
The media type is detected from the file's contents, not its name, so a
mislabelled .mp4 that actually holds a PDF is handled correctly. An .mp4
containing only an audio track is compressed as audio.
When the format changes
Your extension is preserved whenever it can be. It changes only when keeping it would make the target unreachable, and the change is always reported:
| Input | Output | Why |
|---|---|---|
.wav |
.flac |
Lossless and typically 40–60% smaller. Audio is bit-for-bit identical. |
.wav |
.mp3 |
When even FLAC will not fit. |
.mp3 |
.opus |
Only below roughly 48 kbps, where MP3 stops being listenable. |
.png |
.webp |
Last resort, after lossless optimisation, palette reduction and downscaling have all failed. Transparency is preserved. |
.webm |
.mp4 |
Only if this FFmpeg build has no VP9 encoder. |
result.format_changed tells you whether this happened.
How it picks settings
The problem being solved is:
maximise quality subject to output_size < target_size
Not "make it as small as possible" — a 50 MB request should come back at 48.9 MB with good quality, not 12 MB with bad quality.
- Images — binary search over encoder quality at full resolution. PNGs try maximum-effort lossless deflate first, then adaptive palette reduction, and only then downscaling.
- Video — the byte budget is split into audio + container overhead + video, an opening bitrate is derived from the duration, and each measurement corrects the next guess proportionally. Resolution and frame rate are then chosen together (see below).
- Audio — lossless FLAC first for lossless sources. Otherwise the source's own codec at a searched bitrate, dropping to mono, then to a lower sample rate, then to Opus.
- PDF — lossless structural optimisation first (text, fonts and vectors are never touched). Then embedded images are re-encoded at a searched quality. Images that would grow, and stencil masks, are left alone.
Encodes are budgeted (six to eight per run), so a run converges instead of grinding through dozens of attempts.
Long videos are searched on a sample. A single full pass over a long 4K source runs well below realtime, so searching with full passes is painfully slow. Above 90 seconds the search runs on a 30-second slice taken a quarter of the way in, and only the settings that win get a full-length encode. The prediction is never the result: the full encode is measured and validated like any other, and corrected if the sample misjudged the file. The delivered file is a full-quality encode either way.
Resolution and frame rate are traded together
When a video budget is too thin for the source, there are two things to give up: pixels per frame, and frames per second. Sacrificing only resolution — the obvious approach — throws away detail that a small frame-rate cut would have paid for.
A 5-minute 4K clip at 62 fps squeezed into 50 MB is the worst case: those bits have to cover twice as many frames as a 30 fps video, starving every one of them.
| Resolution only | Both levers | |
|---|---|---|
| Result | 640x360 @ 62 fps | 1024x576 @ 31 fps |
| Pixels per frame | 230,400 | 589,824 (2.6x) |
The cost of a combination follows published streaming ladders, where doubling frame rate costs about 1.5x the bitrate rather than 2x — consecutive frames are more alike the faster you sample:
required_bitrate = bits_per_pixel * width * height * (fps / 30) ** 0.585
bits_per_pixel is measured from your own file, not assumed. The source's
bitrate, resolution and frame rate give the figure for free, before anything is
re-encoded — and content varies enormously. A near-static screen recording
measures around 0.26 where typical footage sits near 1.5, so it keeps far
more resolution for the same budget. (The measurement can only lower the
requirement, never raise it: a lavish source proves the content can absorb
bits, not that it needs them.)
Every affordable combination is then ranked, weighting resolution above frame rate, and the best one is encoded. Frame rate is never reduced below 24 fps, never raised, and never touched at all when the budget comfortably covers the source.
That first choice is only a prediction, and measurement overrides it in both directions. Video with little movement — a screen recording, a slide deck — cannot absorb the bitrate it is given: past a point the encoder is already at its best quality for that frame size, and more bits change nothing. When that happens the spare budget is spent on pixels instead of bitrate, climbing back towards the source resolution and frame rate.
If even the source resolution leaves the budget unspent, the encoder switches
from targeting an average bitrate to targeting a quality level (-crf).
That distinction matters: a bitrate target is a request the encoder may decline
when the content is simple, whereas lowering the quality number always produces
more bits. It is what lets a size floor be met at full resolution.
The ceiling is absolute throughout — none of this can cross it.
Both constants are heuristics tuned to established practice, not measurements of your specific clip — very high-motion footage or a slideshow will not match them exactly.
Cut and combine PDFs and videos
Four more commands share one grammar — a file, a range in brackets, and optionally where to save:
add_pdf "D:/a.pdf"[2-9] "D:/b.pdf"[7-16] "D:/out"
cut_pdf "C:/My Documents/book.pdf"[2-5,8-12,20] "D:/out"
add_video "a.mp4"[00:00-00:30] "b.mp4"[01:10-02:00] "D:/out"
cut_video "movie.mp4"[00:02:10-00:05:30] "D:/out"
| Operation | Does | Output name |
|---|---|---|
add_pdf |
pages from two or more PDFs, in order | a_merged.pdf |
cut_pdf |
pages from one PDF | book_pages_7-16.pdf |
add_video |
time ranges from two or more videos, joined | a_joined.mp4 |
cut_video |
time ranges from one video | movie_cut.mp4 |
The folder at the end is optional. Without it the result is saved in the
same folder as the (first) input under a new name — book_pages_7-16.pdf, or
book_pages_7-16_1.pdf if that is taken. An existing file is never replaced.
The last path is the output when it has no [range]; for add_pdf and
add_video, an existing file in that place is one more whole-file input.
PowerShell and zsh. In cmd and bash the lines above work exactly as
written. PowerShell reads "book.pdf"[2-9] as indexing into a string, and zsh
treats [...] as a file pattern, so there put the range inside the quotes —
which works in every shell — or tell PowerShell to leave the line alone with
--%:
cut_pdf "C:/My Documents/book.pdf[2-5,8-12,20]" "D:/out"
cut_pdf --% "C:/My Documents/book.pdf"[2-5,8-12,20] "D:/out"
Quotes around paths are optional: the commands work out where each path ends
even after a shell has removed them. Scripts and LLM agents that start a
command from an argument list, and execute() in Python, take the line
exactly as written.
Ranges
- Pages start at 1:
7,2-9,7-end,2-5,8-12,20,16-7(reversed),all. A page may appear more than once.add_pdftakes whole files when the[...]is left out. - Times:
130(seconds),02:10,00:02:10,1:02:03.5. Ranges arestart-endor01:20-end; several may be joined with commas.
Output. The output folder is created if needed. A target ending in
.pdf / .mp4 / .mkv / .mov / .webm / .avi names the file exactly
(--overwrite to replace one). Existing files are never replaced otherwise,
and an input never is.
What happens to quality
- PDF pages are copied as PDF objects — nothing is rasterised or re-encoded.
- Video is re-encoded (H.264 at CRF 18, visually lossless) so cuts are frame-accurate and clips of different sizes, frame rates or audio can be joined. Clips are fitted inside the first clip's frame without stretching; a clip with no sound contributes silence so audio stays in sync. A progress bar shows the time left.
cut_video --copyskips the re-encode: instant and lossless, but the cut snaps to a keyframe and may start a moment early. One range only.
Every result is written to a temporary file, re-opened and checked (page count with pikepdf, duration and streams with ffprobe), and only then moved into place. Every range is checked before anything is written, so a typo produces an error, not a half-finished file.
From Python:
from ecompress import add_pdf, add_video, cut_pdf, cut_video, execute
execute('cut_pdf "book.pdf"[7-16] "D:/out"')
cut_pdf("book.pdf", "2-5,8-12,20")
add_pdf([("a.pdf", "2-9"), ("b.pdf", "7-16")], output="D:/out")
cut_video("movie.mp4", "00:02:10-00:05:30")
add_video([("a.mp4", (0, 30)), ("b.mp4", "01:10-02:00")])
Each returns an EditResult with output_path, output_size_bytes, pages
or duration_seconds, and elapsed_seconds. Mistakes raise
CommandSyntaxError with a message saying what was expected.
Python API
from ecompress import compress
result = compress(r"D:\Videos\movie.mp4", 50)
print(result.output_path) # D:\Videos\movie_compressed.mp4
print(result.output_size_mb) # 48.87
assert result.output_size_bytes < 50_000_000
CompressionResult carries:
| Field | Meaning |
|---|---|
input_path |
the original file, untouched |
output_path |
absolute path of the result |
input_size_bytes |
original size |
output_size_bytes |
measured size of the result |
target_size_bytes |
the ceiling, target_mb * 1_000_000 |
min_size_bytes |
the floor, when a range was given, else None |
within_requested_range |
whether both ends were satisfied |
saved_bytes |
bytes removed |
reduction_percent |
percentage removed |
media_type |
MediaType.VIDEO, .IMAGE, .AUDIO or .PDF |
attempts |
every measured encode, with its settings and size |
target_achieved |
always True on a returned result |
skipped |
True when the input was already small enough |
format_changed |
True when the extension had to change |
notes |
human-readable explanations of any decisions |
Optional keyword arguments:
compress(
path,
target_mb, # 50, "50", "40-50", (40, 50)
min_mb=None, # a floor, as an alternative to the range form
output_path=None, # write somewhere specific
reporter=None, # progress callbacks; ConsoleReporter() prints them
overwrite=False, # allow output_path to replace an existing file
timeout=None, # seconds per encoder invocation
)
Errors
All of them subclass CompressError:
| Exception | Raised when |
|---|---|
InputFileError |
missing, empty, unreadable, or a directory |
InvalidTargetError |
the target is not a positive, finite number |
UnsupportedFormatError |
no backend handles this file type |
MissingDependencyError |
FFmpeg is needed but not installed |
TargetNotAchievableError |
no valid output fits under the target |
OutputValidationError |
a produced file failed its final check |
from ecompress import compress, TargetNotAchievableError
try:
result = compress("video.mp4", 0.1)
except TargetNotAchievableError as exc:
print(exc.smallest_valid_bytes) # what was actually achievable
Command-line options
The two positional arguments are the whole primary interface. These exist for scripting:
-o, --output PATH write here instead of <name>_compressed<ext>
--overwrite allow --output to replace an existing file
-q, --quiet print only the output path
--json print the result as JSON
--timeout SEC give up on a single encoder run after this long
--version
Exit codes: 0 success, 1 target not achievable, 2 bad input or usage,
3 FFmpeg missing.
ecompress "movie.mp4" 50 --json
{
"input_path": "D:\\Videos\\movie.mp4",
"output_path": "D:\\Videos\\movie_compressed.mp4",
"output_size_bytes": 48871234,
"target_size_bytes": 50000000,
"reduction_percent": 40.6912,
"media_type": "video",
"attempts": 3,
"target_achieved": true
}
What this tool will not do
The size guarantee is met by genuinely re-encoding, never by faking it. The package will not truncate files, strip bytes, break containers, rename extensions to disguise content, or write sparse files. If a valid file below the target cannot be produced, it says so and writes nothing.
Notes and limits
- Sizes are decimal (
1 MB = 1,000,000 bytes), matching how storage is sold and how upload limits are usually quoted. Windows Explorer shows binary MB (1,048,576 bytes), so it will report a slightly smaller number than this tool does — never a larger one. - A video needs a readable duration for bitrate targeting; without one the tool falls back to a quality-based search.
- A PDF that is pure text or vector art cannot be shrunk much. Rather than mangling the document, the tool reports what it managed and why.
- Subtitle, data and chapter tracks are dropped from re-encoded video.
- Encrypted or password-protected PDFs are rejected with a clear message.
- FFmpeg can be pointed at explicitly with the
COMPRESS_FFMPEGandCOMPRESS_FFPROBEenvironment variables; Ghostscript withCOMPRESS_GHOSTSCRIPT.
Development
git clone https://github.com/priyadip/compress-cli
cd ecompress
pip install -e ".[dev]"
pytest # add -m "not slow" to skip real encodes
ruff check . && ruff format --check .
mypy .
python -m build && twine check dist/*
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ecompress-2.5.0.tar.gz.
File metadata
- Download URL: ecompress-2.5.0.tar.gz
- Upload date:
- Size: 129.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4e8f49e8f5cf639726cfffe438696270a714fe708d22b5fb370dffb2c8779397
|
|
| MD5 |
cad6ad6e9a2e1680b8bc0e3981fbae1b
|
|
| BLAKE2b-256 |
3c5896a908809351b7abde053fd6883e8fa57e17c2c36a086d4ee3ec2d27eaf7
|
File details
Details for the file ecompress-2.5.0-py3-none-any.whl.
File metadata
- Download URL: ecompress-2.5.0-py3-none-any.whl
- Upload date:
- Size: 96.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8bf0ee023f1972a4122ef06f3b4a922b00fc1a95ddd38cbb1bc7907ef3dba541
|
|
| MD5 |
94d7d4ec2d9dc5fa886bae5b93c34d31
|
|
| BLAKE2b-256 |
9a7bdfa5c423692e1ce13e15a48f1182c31245c30a134c6bbd9c795f1b99931a
|