tinyfy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tinyfy/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
tinyfy/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from tinyfy.cli import main
2
+
3
+ raise SystemExit(main())
tinyfy/cli.py ADDED
@@ -0,0 +1,224 @@
1
+ """tinyfy — compress any file, locally. Installed as `tiny` (and `tinyfy`).
2
+
3
+ tiny photo.jpg video.mov report.pdf
4
+ tiny -l high --max-dim 1920 photos/ --each
5
+ tiny project/ -f zst # pack a folder -> project.tar.zst
6
+ tiny report.pdf -o out/small.pdf # write to an exact file
7
+ tiny -x project.tar.zst # extract
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from tinyfy import __version__
22
+ from tinyfy.common import LEVELS, Options, Skip, human, path_size
23
+ from tinyfy.handlers import generic, pick
24
+
25
+ FORMATS = ("xz", "gz", "zst", "zip")
26
+
27
+
28
+ @dataclass
29
+ class Task:
30
+ src: Path
31
+ out_dir: Path
32
+ mirror: bool = False # always leave a file in out_dir (copy the original if it can't be shrunk)
33
+ smart_only: bool = False # --each: never turn unknown files into .xz
34
+ out_file: Path | None = None # -o points at an exact file
35
+
36
+
37
+ @dataclass
38
+ class Result:
39
+ src: Path
40
+ status: str # ok | copied | skip | error
41
+ before: int = 0
42
+ after: int = 0
43
+ out: Path | None = None
44
+ note: str = ""
45
+
46
+
47
+ def _prog_name() -> str:
48
+ name = Path(sys.argv[0]).stem
49
+ return name if name in ("tiny", "tinyfy") else "tiny"
50
+
51
+
52
+ def build_parser() -> argparse.ArgumentParser:
53
+ p = argparse.ArgumentParser(
54
+ prog=_prog_name(),
55
+ description="Compress any file: images, PDFs, video, audio, Office documents, folders and everything else.",
56
+ epilog=__doc__.split("\n", 2)[2],
57
+ formatter_class=argparse.RawDescriptionHelpFormatter,
58
+ )
59
+ p.add_argument("paths", nargs="+", type=Path, help="files or folders to compress")
60
+ p.add_argument("-l", "--level", choices=LEVELS, default="medium",
61
+ help="compression level: low (best quality), medium (default), high (smallest)")
62
+ p.add_argument("-o", "--output",
63
+ help="output file (e.g. out/small.pdf) or output folder (e.g. out/ or an existing folder); "
64
+ "default: next to the original with a .min suffix")
65
+ p.add_argument("-f", "--format", dest="fmt", choices=FORMATS,
66
+ help="lossless format for other files/folders "
67
+ "(default: taken from the -o extension if given, otherwise xz)")
68
+ p.add_argument("--lossless", action="store_true",
69
+ help="lossless only: keep every byte, output is .xz/.zst/...")
70
+ p.add_argument("--each", action="store_true",
71
+ help="for folders: compress each file inside instead of packing, into <name>.min/")
72
+ p.add_argument("--max-dim", type=int, metavar="PX", help="downscale images/videos so the longest side is <= PX")
73
+ p.add_argument("--codec", dest="video_codec", choices=("h264", "h265"), default="h264",
74
+ help="video codec (h265 is smaller but slower and less compatible)")
75
+ p.add_argument("--in-place", action="store_true", help="replace the original (only when the result is smaller)")
76
+ p.add_argument("--force", action="store_true", help="keep the result even if it is not smaller")
77
+ p.add_argument("-j", "--jobs", type=int, default=min(4, os.cpu_count() or 1), help="files to process in parallel")
78
+ p.add_argument("-x", "--extract", action="store_true", help="extract .xz/.gz/.zst/.bz2/.zip/.tar.* files")
79
+ p.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
80
+ return p
81
+
82
+
83
+ def output_file(args: argparse.Namespace, parser: argparse.ArgumentParser) -> Path | None:
84
+ """-o is a file if it has an extension, doesn't end with '/' and isn't an existing folder."""
85
+ raw = args.output
86
+ args.output = Path(raw) if raw else None
87
+ out = args.output
88
+ if out is None or raw.endswith(("/", os.sep)) or out.is_dir() or not out.suffix:
89
+ return None
90
+ if len(args.paths) != 1 or args.each:
91
+ parser.error("when -o is a file, exactly one input is allowed (and no --each)")
92
+ if args.in_place:
93
+ parser.error("-o cannot be combined with --in-place")
94
+ return out
95
+
96
+
97
+ def main(argv: list[str] | None = None) -> int:
98
+ parser = build_parser()
99
+ args = parser.parse_args(argv)
100
+ out_file = output_file(args, parser)
101
+ if args.extract:
102
+ return _extract(args, out_file)
103
+
104
+ fmt = args.fmt
105
+ if fmt is None and out_file is not None:
106
+ # -o project.tar.zst / log.gz -> take the format from the extension
107
+ fmt = {".tgz": "gz", ".tzst": "zst"}.get(out_file.suffix.lower(), out_file.suffix.lower().lstrip("."))
108
+ opts = Options(level=args.level, max_dim=args.max_dim, video_codec=args.video_codec,
109
+ fmt=fmt if fmt in FORMATS else "xz", lossless=args.lossless)
110
+ tasks: list[Task] = []
111
+ missing = False
112
+ for p in args.paths:
113
+ if not p.exists():
114
+ print(f"✗ not found: {p}", file=sys.stderr)
115
+ missing = True
116
+ continue
117
+ if p.is_dir() and args.each:
118
+ root = args.output or p.parent / f"{p.name}.min"
119
+ if args.in_place:
120
+ root = p
121
+ for f in sorted(p.rglob("*")):
122
+ if f.is_file():
123
+ tasks.append(Task(f, root / f.relative_to(p).parent, mirror=not args.in_place, smart_only=True))
124
+ else:
125
+ tasks.append(Task(p, args.output or p.parent, out_file=out_file))
126
+
127
+ if not tasks:
128
+ return 1
129
+
130
+ def work(t: Task) -> Result:
131
+ r = process(t, opts, in_place=args.in_place and not t.src.is_dir(), force=args.force)
132
+ report(r)
133
+ return r
134
+
135
+ with ThreadPoolExecutor(max(1, args.jobs)) as pool:
136
+ results = list(pool.map(work, tasks))
137
+ summary(results)
138
+ return 1 if missing or any(r.status == "error" for r in results) else 0
139
+
140
+
141
+ def process(t: Task, opts: Options, in_place: bool, force: bool) -> Result:
142
+ src = t.src
143
+ before = path_size(src)
144
+ res = Result(src, "error", before=before)
145
+ try:
146
+ handler = pick(src, opts, smart_only=t.smart_only)
147
+ if handler is None:
148
+ raise Skip("no dedicated compressor for this type")
149
+ with tempfile.TemporaryDirectory(prefix="tinyfy-") as tmp:
150
+ produced = handler(src, Path(tmp), opts)
151
+ after = produced.stat().st_size
152
+ if after >= before and not force:
153
+ raise Skip(f"result is not smaller ({human(after)})")
154
+ dest = t.out_file or _destination(src, produced.name, t.out_dir, in_place)
155
+ if dest.is_dir():
156
+ raise IsADirectoryError(f"{dest} is a folder, refusing to overwrite it")
157
+ dest.parent.mkdir(parents=True, exist_ok=True)
158
+ shutil.move(produced, dest) # overwrites a previous result
159
+ if in_place and dest != src:
160
+ src.unlink() # extension changed (e.g. .mov -> .mp4): remove the original
161
+ res.status, res.after, res.out = "ok", after, dest
162
+ except Skip as e:
163
+ res.status, res.after, res.note = "skip", before, str(e)
164
+ if t.mirror:
165
+ dest = t.out_dir / src.name
166
+ dest.parent.mkdir(parents=True, exist_ok=True)
167
+ shutil.copy2(src, dest)
168
+ res.status, res.out = "copied", dest
169
+ except Exception as e: # noqa: BLE001 — one bad file must not stop the whole run
170
+ res.note = f"{type(e).__name__}: {e}"
171
+ return res
172
+
173
+
174
+ def _destination(src: Path, name: str, out_dir: Path, in_place: bool) -> Path:
175
+ if in_place:
176
+ return src.with_name(name)
177
+ dest = out_dir / name
178
+ if dest.resolve() == src.resolve():
179
+ # same folder, same name -> insert .min before the extension
180
+ stem, dot, ext = name.rpartition(".")
181
+ dest = out_dir / (f"{stem}.min.{ext}" if dot else f"{name}.min")
182
+ return dest
183
+
184
+
185
+ def report(r: Result) -> None:
186
+ if r.status == "ok":
187
+ saved = 100 * (1 - r.after / r.before) if r.before else 0
188
+ print(f"✓ {r.src} {human(r.before)} → {human(r.after)} (-{saved:.0f}%) ⇒ {r.out}")
189
+ elif r.status == "copied":
190
+ print(f"= {r.src} copied unchanged ({r.note})")
191
+ elif r.status == "skip":
192
+ print(f"- {r.src} skipped: {r.note}")
193
+ else:
194
+ print(f"✗ {r.src} error: {r.note}", file=sys.stderr)
195
+
196
+
197
+ def summary(results: list[Result]) -> None:
198
+ if len(results) < 2:
199
+ return
200
+ before = sum(r.before for r in results if r.status in ("ok", "copied"))
201
+ after = sum(r.after for r in results if r.status in ("ok", "copied"))
202
+ counts = {s: sum(r.status == s for r in results) for s in ("ok", "copied", "skip", "error")}
203
+ saved = 100 * (1 - after / before) if before else 0
204
+ print(f"\nTotal: {counts['ok']} compressed, {counts['copied'] + counts['skip']} unchanged/skipped, "
205
+ f"{counts['error']} failed — {human(before)} → {human(after)} (-{saved:.0f}%)")
206
+
207
+
208
+ def _extract(args: argparse.Namespace, out_file: Path | None) -> int:
209
+ code = 0
210
+ for p in args.paths:
211
+ try:
212
+ if out_file:
213
+ out = generic.extract(p, out_file.parent, out_file)
214
+ else:
215
+ out = generic.extract(p, args.output or p.parent)
216
+ print(f"✓ {p} ⇒ {out}")
217
+ except Exception as e: # noqa: BLE001
218
+ print(f"✗ {p} error: {e}", file=sys.stderr)
219
+ code = 1
220
+ return code
221
+
222
+
223
+ if __name__ == "__main__":
224
+ raise SystemExit(main())
tinyfy/common.py ADDED
@@ -0,0 +1,53 @@
1
+ """Shared types and helpers used by the CLI and all handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ LEVELS = ("low", "medium", "high")
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Options:
15
+ level: str = "medium" # low = best quality, high = smallest
16
+ max_dim: int | None = None # longest side (px) for images/videos
17
+ video_codec: str = "h264" # h264 | h265
18
+ fmt: str = "xz" # lossless format: xz | gz | zst | zip
19
+ lossless: bool = False # lossless compression only
20
+
21
+
22
+ class Skip(Exception):
23
+ """The file should not or cannot be compressed (already compressed, missing tool, ...)."""
24
+
25
+
26
+ def need_tool(name: str, *alternatives: str) -> str:
27
+ """Find an external program, trying alternative names (e.g. Ghostscript is gswin64c on Windows)."""
28
+ for candidate in (name, *alternatives):
29
+ path = shutil.which(candidate)
30
+ if path:
31
+ return path
32
+ raise Skip(f"install '{name}' to compress this file type")
33
+
34
+
35
+ def run(cmd: list[str]) -> None:
36
+ proc = subprocess.run(cmd, capture_output=True, text=True)
37
+ if proc.returncode != 0:
38
+ msg = (proc.stderr or proc.stdout).strip().splitlines()
39
+ raise RuntimeError(msg[-1] if msg else f"{Path(cmd[0]).name} failed (exit code {proc.returncode})")
40
+
41
+
42
+ def path_size(path: Path) -> int:
43
+ if path.is_dir():
44
+ return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
45
+ return path.stat().st_size
46
+
47
+
48
+ def human(n: float) -> str:
49
+ for unit in ("B", "KB", "MB", "GB"):
50
+ if abs(n) < 1024:
51
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
52
+ n /= 1024
53
+ return f"{n:.1f} TB"
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Callable
5
+
6
+ from tinyfy.common import Options
7
+ from tinyfy.handlers import generic, image, media, office, pdf
8
+
9
+ Handler = Callable[[Path, Path, Options], Path]
10
+
11
+ _SMART: list[tuple[set[str], Handler]] = [
12
+ (image.EXTS, image.run),
13
+ (pdf.EXTS, pdf.run),
14
+ (media.EXTS, media.run),
15
+ (office.EXTS, office.run),
16
+ ]
17
+
18
+
19
+ def pick(src: Path, opts: Options, smart_only: bool = False) -> Handler | None:
20
+ """Pick a handler by extension. smart_only: return None instead of falling back to lossless."""
21
+ if src.is_dir() or opts.lossless:
22
+ return generic.run
23
+ ext = src.suffix.lower()
24
+ for exts, handler in _SMART:
25
+ if ext in exts:
26
+ return handler
27
+ return None if smart_only else generic.run
@@ -0,0 +1,133 @@
1
+ """Lossless compression for any file/folder: xz, gz, zst, zip (folders -> tar.*)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import lzma
7
+ import shutil
8
+ import tarfile
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ from tinyfy.common import Options, Skip, need_tool, run as run_cmd
13
+
14
+ # Already compressed; compressing again gains almost nothing
15
+ ALREADY_COMPRESSED = {
16
+ ".zip", ".gz", ".tgz", ".xz", ".txz", ".bz2", ".zst", ".7z", ".rar", ".lz4", ".br", ".lzma",
17
+ ".gif", ".heic", ".heif", ".avif", ".jxl", ".apk", ".jar", ".dmg", ".whl",
18
+ }
19
+
20
+ XZ_PRESET = {"low": 3, "medium": 6, "high": 9 | lzma.PRESET_EXTREME}
21
+ GZ_LEVEL = {"low": 6, "medium": 9, "high": 9}
22
+ ZST_LEVEL = {"low": 3, "medium": 12, "high": 19}
23
+ ZIP_LEVEL = {"low": 6, "medium": 9, "high": 9}
24
+
25
+ CHUNK = 1 << 20
26
+
27
+
28
+ def run(src: Path, tmp: Path, opts: Options) -> Path:
29
+ if src.is_dir():
30
+ return _dir(src, tmp, opts)
31
+ if src.suffix.lower() in ALREADY_COMPRESSED:
32
+ raise Skip("already compressed")
33
+ return _file(src, tmp, opts)
34
+
35
+
36
+ def _file(src: Path, tmp: Path, opts: Options) -> Path:
37
+ fmt, lvl = opts.fmt, opts.level
38
+ out = tmp / f"{src.name}.{fmt}"
39
+ if fmt == "xz":
40
+ with src.open("rb") as fi, lzma.open(out, "wb", preset=XZ_PRESET[lvl]) as fo:
41
+ shutil.copyfileobj(fi, fo, CHUNK)
42
+ elif fmt == "gz":
43
+ with src.open("rb") as fi, gzip.open(out, "wb", compresslevel=GZ_LEVEL[lvl]) as fo:
44
+ shutil.copyfileobj(fi, fo, CHUNK)
45
+ elif fmt == "zst":
46
+ _zstd(src, out, lvl)
47
+ elif fmt == "zip":
48
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=ZIP_LEVEL[lvl]) as z:
49
+ z.write(src, src.name)
50
+ else:
51
+ raise ValueError(fmt)
52
+ return out
53
+
54
+
55
+ def _dir(src: Path, tmp: Path, opts: Options) -> Path:
56
+ fmt, lvl = opts.fmt, opts.level
57
+ if fmt == "zip":
58
+ out = tmp / f"{src.name}.zip"
59
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=ZIP_LEVEL[lvl]) as z:
60
+ for f in sorted(src.rglob("*")):
61
+ z.write(f, f.relative_to(src.parent))
62
+ return out
63
+ out = tmp / f"{src.name}.tar.{fmt}"
64
+ if fmt == "xz":
65
+ with tarfile.open(out, "w:xz", preset=XZ_PRESET[lvl]) as t:
66
+ t.add(src, src.name)
67
+ elif fmt == "gz":
68
+ with tarfile.open(out, "w:gz", compresslevel=GZ_LEVEL[lvl]) as t:
69
+ t.add(src, src.name)
70
+ elif fmt == "zst":
71
+ plain = tmp / f"{src.name}.tar"
72
+ with tarfile.open(plain, "w") as t:
73
+ t.add(src, src.name)
74
+ _zstd(plain, out, lvl)
75
+ plain.unlink()
76
+ else:
77
+ raise ValueError(fmt)
78
+ return out
79
+
80
+
81
+ def _zstd(src: Path, out: Path, lvl: str) -> None:
82
+ zstd = need_tool("zstd")
83
+ run_cmd([zstd, "-q", "-f", "-T0", "--long=27", f"-{ZST_LEVEL[lvl]}", str(src), "-o", str(out)])
84
+
85
+
86
+ # ---------- extraction ----------
87
+
88
+ def extract(src: Path, dest: Path, out_file: Path | None = None) -> Path:
89
+ """Extract into folder dest (overwriting existing files). out_file: exact target for .xz/.gz/.zst/.bz2."""
90
+ name = src.name.lower()
91
+ dest.mkdir(parents=True, exist_ok=True)
92
+ is_archive = name.endswith((".tar.zst", ".tzst")) or tarfile.is_tarfile(src) or zipfile.is_zipfile(src)
93
+ if out_file and is_archive:
94
+ raise Skip("this archive holds multiple files, -o must be a folder")
95
+ if name.endswith((".tar.zst", ".tzst")):
96
+ zstd = need_tool("zstd")
97
+ plain = dest / (src.name.rsplit(".", 1)[0] + ".tmp.tar")
98
+ run_cmd([zstd, "-d", "-q", "-f", "--long=31", str(src), "-o", str(plain)])
99
+ try:
100
+ _untar(plain, dest)
101
+ finally:
102
+ plain.unlink()
103
+ return dest
104
+ if tarfile.is_tarfile(src):
105
+ _untar(src, dest)
106
+ return dest
107
+ if zipfile.is_zipfile(src):
108
+ with zipfile.ZipFile(src) as z:
109
+ z.extractall(dest)
110
+ return dest
111
+ out = out_file or dest / src.name.rsplit(".", 1)[0]
112
+ if out.is_dir():
113
+ raise IsADirectoryError(f"{out} is a folder, refusing to overwrite it")
114
+ if name.endswith(".zst"):
115
+ run_cmd([need_tool("zstd"), "-d", "-q", "-f", "--long=31", str(src), "-o", str(out)])
116
+ return out
117
+ opener = {".xz": lzma.open, ".lzma": lzma.open, ".gz": gzip.open}.get(src.suffix.lower())
118
+ if opener is None:
119
+ import bz2
120
+ if src.suffix.lower() != ".bz2":
121
+ raise Skip("unrecognized compression format")
122
+ opener = bz2.open
123
+ with opener(src, "rb") as fi, out.open("wb") as fo:
124
+ shutil.copyfileobj(fi, fo, CHUNK)
125
+ return out
126
+
127
+
128
+ def _untar(src: Path, dest: Path) -> None:
129
+ with tarfile.open(src) as t:
130
+ try:
131
+ t.extractall(dest, filter="data")
132
+ except TypeError: # Python < 3.10.12 has no filter argument
133
+ t.extractall(dest)
@@ -0,0 +1,61 @@
1
+ """Images: re-encode JPEG/WebP by quality, optimize PNG (lossy at high), BMP -> PNG, TIFF -> deflate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from pathlib import Path
7
+
8
+ from tinyfy.common import Options, Skip
9
+
10
+ EXTS = {".jpg", ".jpeg", ".jfif", ".png", ".webp", ".bmp", ".tif", ".tiff"}
11
+
12
+ QUALITY = {"low": 85, "medium": 75, "high": 60}
13
+
14
+
15
+ def _pil():
16
+ try:
17
+ from PIL import Image, ImageOps
18
+ except ImportError:
19
+ raise Skip("install Pillow (pip install Pillow) to compress images")
20
+ return Image, ImageOps
21
+
22
+
23
+ def compress_bytes(data: bytes, ext: str, opts: Options) -> bytes:
24
+ """Compress an image in memory, keeping its format. Used for single files and images inside Office files."""
25
+ Image, ImageOps = _pil()
26
+ ext = ext.lower()
27
+ with Image.open(io.BytesIO(data)) as src:
28
+ if getattr(src, "n_frames", 1) > 1:
29
+ raise Skip("animated/multi-page image")
30
+ icc = src.info.get("icc_profile")
31
+ im = ImageOps.exif_transpose(src)
32
+ if opts.max_dim:
33
+ im.thumbnail((opts.max_dim, opts.max_dim), Image.LANCZOS)
34
+
35
+ out = io.BytesIO()
36
+ q = QUALITY[opts.level]
37
+ if ext in (".jpg", ".jpeg", ".jfif"):
38
+ if im.mode not in ("RGB", "L"):
39
+ im = im.convert("RGB")
40
+ im.save(out, "JPEG", quality=q, optimize=True, progressive=True, icc_profile=icc)
41
+ elif ext == ".webp":
42
+ im.save(out, "WEBP", quality=q, method=6, icc_profile=icc)
43
+ elif ext in (".png", ".bmp"):
44
+ if opts.level == "high" and im.mode not in ("P", "L", "1"):
45
+ if im.mode not in ("RGB", "RGBA"):
46
+ im = im.convert("RGBA")
47
+ im = im.quantize(256, method=Image.Quantize.FASTOCTREE)
48
+ im.save(out, "PNG", optimize=True, icc_profile=icc)
49
+ elif ext in (".tif", ".tiff"):
50
+ im.save(out, "TIFF", compression="tiff_adobe_deflate", icc_profile=icc)
51
+ else:
52
+ raise Skip(f"unsupported image type {ext}")
53
+ return out.getvalue()
54
+
55
+
56
+ def run(src: Path, tmp: Path, opts: Options) -> Path:
57
+ ext = src.suffix.lower()
58
+ # BMP is uncompressed -> save as PNG (lossless at low/medium)
59
+ out = tmp / (src.stem + ".png" if ext == ".bmp" else src.name)
60
+ out.write_bytes(compress_bytes(src.read_bytes(), ext, opts))
61
+ return out
@@ -0,0 +1,65 @@
1
+ """Video and audio through ffmpeg."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from tinyfy.common import Options, need_tool, run as run_cmd
8
+
9
+ VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".wmv", ".flv", ".webm", ".m4v", ".mpg", ".mpeg", ".3gp", ".ts"}
10
+ AUDIO_EXTS = {".mp3", ".wav", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".ogg", ".opus", ".wma"}
11
+ EXTS = VIDEO_EXTS | AUDIO_EXTS
12
+
13
+ CRF = {
14
+ "h264": {"low": 23, "medium": 26, "high": 30},
15
+ "h265": {"low": 26, "medium": 28, "high": 32},
16
+ }
17
+ VIDEO_AUDIO_BITRATE = {"low": "160k", "medium": "128k", "high": "96k"}
18
+ AUDIO_BITRATE = {"low": "192k", "medium": "128k", "high": "96k"}
19
+ OPUS_BITRATE = {"low": "128k", "medium": "96k", "high": "64k"}
20
+
21
+
22
+ def _ffmpeg(args: list[str]) -> None:
23
+ run_cmd([need_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-y", *args])
24
+
25
+
26
+ def run(src: Path, tmp: Path, opts: Options) -> Path:
27
+ if src.suffix.lower() in VIDEO_EXTS:
28
+ return _video(src, tmp, opts)
29
+ return _audio(src, tmp, opts)
30
+
31
+
32
+ def _video(src: Path, tmp: Path, opts: Options) -> Path:
33
+ out = tmp / (src.stem + ".mp4")
34
+ # yuv420p needs even dimensions
35
+ vf = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
36
+ if opts.max_dim:
37
+ d = opts.max_dim
38
+ vf = f"scale='min(iw,{d})':'min(ih,{d})':force_original_aspect_ratio=decrease," + vf
39
+ codec = ["-c:v", "libx264", "-preset", "slow"]
40
+ if opts.video_codec == "h265":
41
+ codec = ["-c:v", "libx265", "-preset", "medium", "-tag:v", "hvc1", "-x265-params", "log-level=error"]
42
+ _ffmpeg([
43
+ "-i", str(src),
44
+ "-map", "0:v:0", "-map", "0:a?", "-map_metadata", "0",
45
+ *codec, "-crf", str(CRF[opts.video_codec][opts.level]), "-pix_fmt", "yuv420p", "-vf", vf,
46
+ "-c:a", "aac", "-b:a", VIDEO_AUDIO_BITRATE[opts.level],
47
+ "-movflags", "+faststart", str(out),
48
+ ])
49
+ return out
50
+
51
+
52
+ def _audio(src: Path, tmp: Path, opts: Options) -> Path:
53
+ ext = src.suffix.lower()
54
+ lvl = opts.level
55
+ if ext in (".wav", ".aiff", ".aif", ".flac") and lvl == "low":
56
+ out, codec = tmp / (src.stem + ".flac"), ["-c:a", "flac", "-compression_level", "12"]
57
+ elif ext in (".m4a", ".aac"):
58
+ out, codec = tmp / (src.stem + ".m4a"), ["-c:a", "aac", "-b:a", AUDIO_BITRATE[lvl]]
59
+ elif ext in (".ogg", ".opus"):
60
+ out, codec = tmp / src.name, ["-c:a", "libopus", "-b:a", OPUS_BITRATE[lvl]]
61
+ else:
62
+ # mp3 is the most compatible; wav/flac at medium/high also become mp3
63
+ out, codec = tmp / (src.stem + ".mp3"), ["-c:a", "libmp3lame", "-b:a", AUDIO_BITRATE[lvl]]
64
+ _ffmpeg(["-i", str(src), "-map", "0:a:0", "-map_metadata", "0", *codec, str(out)])
65
+ return out
@@ -0,0 +1,38 @@
1
+ """Office/ODF/EPUB (zip containers): recompress embedded images and re-zip at maximum level."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import zipfile
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from tinyfy.common import Options, Skip
9
+ from tinyfy.handlers import image
10
+
11
+ EXTS = {".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp", ".epub"}
12
+
13
+ MIN_IMAGE_BYTES = 20 * 1024
14
+
15
+
16
+ def run(src: Path, tmp: Path, opts: Options) -> Path:
17
+ out = tmp / src.name
18
+ with zipfile.ZipFile(src) as zin, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zout:
19
+ infos = zin.infolist()
20
+ # ODF/EPUB require 'mimetype' to be the first entry, stored uncompressed
21
+ infos.sort(key=lambda i: i.filename != "mimetype")
22
+ for info in infos:
23
+ data = zin.read(info)
24
+ if info.filename == "mimetype":
25
+ zout.writestr(info, data, compress_type=zipfile.ZIP_STORED)
26
+ continue
27
+ ext = PurePosixPath(info.filename).suffix.lower()
28
+ if ext in image.EXTS and ext not in (".bmp", ".tif", ".tiff") and len(data) >= MIN_IMAGE_BYTES:
29
+ try:
30
+ smaller = image.compress_bytes(data, ext, opts)
31
+ if len(smaller) < len(data):
32
+ data = smaller
33
+ except Skip:
34
+ pass
35
+ except Exception:
36
+ pass # unreadable image: keep it as is
37
+ zout.writestr(info.filename, data, compress_type=zipfile.ZIP_DEFLATED)
38
+ return out
tinyfy/handlers/pdf.py ADDED
@@ -0,0 +1,24 @@
1
+ """PDF: rewrite through Ghostscript, downsampling embedded images by level."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from tinyfy.common import Options, need_tool, run as run_cmd
8
+
9
+ EXTS = {".pdf"}
10
+
11
+ PRESET = {"low": "/printer", "medium": "/ebook", "high": "/screen"}
12
+
13
+
14
+ def run(src: Path, tmp: Path, opts: Options) -> Path:
15
+ gs = need_tool("gs", "gswin64c", "gswin32c")
16
+ out = tmp / src.name
17
+ run_cmd([
18
+ gs, "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.5",
19
+ f"-dPDFSETTINGS={PRESET[opts.level]}",
20
+ "-dDetectDuplicateImages=true", "-dCompressFonts=true", "-dSubsetFonts=true",
21
+ "-dNOPAUSE", "-dBATCH", "-dQUIET", "-dSAFER",
22
+ f"-sOutputFile={out}", str(src),
23
+ ])
24
+ return out
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinyfy
3
+ Version: 0.1.0
4
+ Summary: Compress any file locally: images, PDFs, video, audio, Office documents, folders and more
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/khanhle-dev/tinyfy
7
+ Project-URL: Source, https://github.com/khanhle-dev/tinyfy
8
+ Project-URL: Issues, https://github.com/khanhle-dev/tinyfy/issues
9
+ Keywords: compress,compression,image,pdf,video,ffmpeg,ghostscript,cli
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: System :: Archiving :: Compression
17
+ Classifier: Topic :: Multimedia :: Graphics
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: Pillow>=9.1
23
+ Dynamic: license-file
24
+
25
+ # tinyfy
26
+
27
+ Compress any file from the command line, entirely on your machine. Each file type gets the method that suits it best:
28
+
29
+ | Type | Extensions | How |
30
+ | --- | --- | --- |
31
+ | Images | jpg, png, webp, bmp, tiff | Re-encode with Pillow, optimize PNG (palette reduction at `high`), BMP → PNG |
32
+ | PDF | pdf | Ghostscript (`/printer`, `/ebook`, `/screen`) |
33
+ | Video | mp4, mov, mkv, avi, webm, ... | ffmpeg → MP4 H.264 (or H.265 with `--codec h265`) |
34
+ | Audio | mp3, wav, flac, m4a, ogg, ... | ffmpeg (wav/flac → FLAC at `low`, MP3 otherwise) |
35
+ | Office | docx, xlsx, pptx, odt, epub, ... | Recompress embedded images and re-zip at maximum level |
36
+ | Everything else | any file or folder | Lossless: xz (default), gz, zst, zip; folders → `tar.*` |
37
+
38
+ If the compressed result is **not smaller** than the original, the original is kept (unless you pass `--force`).
39
+ Originals are never overwritten unless you pass `--in-place`.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pipx install tinyfy # recommended
45
+ pip install tinyfy # or inside a virtualenv
46
+ ```
47
+
48
+ This installs the `tiny` command (`tinyfy` also works as an alias).
49
+
50
+ Requires Python 3.10+. Images and lossless compression work out of the box. Other formats need these programs on your `PATH`:
51
+
52
+ | Program | Needed for | Debian/Ubuntu | macOS | Windows |
53
+ | --- | --- | --- | --- | --- |
54
+ | ffmpeg | video, audio | `sudo apt install ffmpeg` | `brew install ffmpeg` | `winget install Gyan.FFmpeg` |
55
+ | Ghostscript | PDF | `sudo apt install ghostscript` | `brew install ghostscript` | installer from [ghostscript.com](https://ghostscript.com/releases/gsdnld.html) |
56
+ | zstd | `-f zst` | `sudo apt install zstd` | `brew install zstd` | release from [facebook/zstd](https://github.com/facebook/zstd/releases) |
57
+
58
+ If a program is missing, files that need it are skipped with a message; everything else still runs.
59
+
60
+ ## Usage
61
+
62
+ ```bash
63
+ tiny photo.jpg video.mov report.pdf # → photo.min.jpg, video.mp4, report.min.pdf
64
+ tiny -l high --max-dim 1920 *.jpg -o small/ # stronger compression, downscale, save into small/
65
+ tiny report.pdf -o out/small.pdf # write to an exact file
66
+ tiny project/ # pack a folder → project.tar.xz
67
+ tiny project/ -o project.tar.zst # format taken from the extension
68
+ tiny photos/ --each # compress each file → photos.min/ (same structure)
69
+ tiny report.docx --lossless # keep every byte → report.docx.xz
70
+ tiny -x project.tar.zst # extract
71
+ ```
72
+
73
+ ### Options
74
+
75
+ | Option | Description |
76
+ | --- | --- |
77
+ | `-l, --level low\|medium\|high` | `low` keeps the best quality, `high` gives the smallest files. Default: `medium`. |
78
+ | `-o PATH` | A path with an extension (`out/small.pdf`, `project.tar.zst`) is written as that exact file. A path ending in `/`, or an existing folder, is used as the output folder. |
79
+ | `-f xz\|gz\|zst\|zip` | Lossless format for other files and folders. Defaults to the `-o` extension, otherwise `xz`. |
80
+ | `--lossless` | Use lossless compression for every file, so the original can be restored byte for byte. |
81
+ | `--each` | For folders: compress each file inside instead of packing them into one archive. Files without a dedicated compressor are copied unchanged. |
82
+ | `--max-dim PX` | Downscale images and videos so the longest side is at most `PX`. |
83
+ | `--codec h264\|h265` | Video codec. H.265 is smaller but slower to encode and less widely supported. |
84
+ | `--in-place` | Replace the original when the result is smaller. |
85
+ | `--force` | Keep the result even when it is not smaller. |
86
+ | `-j N` | Number of files processed in parallel. |
87
+ | `-x, --extract` | Extract `.xz`, `.gz`, `.zst`, `.bz2`, `.zip` and `.tar.*` files. |
88
+
89
+ Re-running a command overwrites the previous output.
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ python3 -m venv .venv && .venv/bin/pip install -e .
95
+ .venv/bin/python -m unittest discover tests
96
+ ```
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,16 @@
1
+ tinyfy/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ tinyfy/__main__.py,sha256=WcYZdNF2k1mDP7E0omUDLbKbzUPuFjrJ0dapwJwSznA,54
3
+ tinyfy/cli.py,sha256=4yFDxgdLECUR0zCNu2FpchfDU3AzH3bQcJgtOqfNIBM,9395
4
+ tinyfy/common.py,sha256=VYkxkF2p65TOcXCOx5-qf9s7tKOELxUy_UB3VgnZMRs,1748
5
+ tinyfy/handlers/__init__.py,sha256=1t_-rVU3AjyFqknqtt1bJWj0p8NSaCW1HM9jjlS-7Gc,803
6
+ tinyfy/handlers/generic.py,sha256=gn-vp0FtUk9S8hKHn4gCZxcTMllX3V1qwcf5PrS4_rI,4762
7
+ tinyfy/handlers/image.py,sha256=qmt1bH7Ob4E5oxsZOWRF1ZsxrE8H8pPKf3CylaBkE8Y,2334
8
+ tinyfy/handlers/media.py,sha256=0rAMYMkY0cgum7Cj8itaxcS0DBta8u5J18PnhPJ9sVM,2654
9
+ tinyfy/handlers/office.py,sha256=RVPLGkR8k_hvRtMhqr1F8aAzITnQUFYZhvtkc-K_cjk,1524
10
+ tinyfy/handlers/pdf.py,sha256=OV_H7MQLAEwMioWweip1SMFOhuwkGnVyyNh2uzMY0eA,744
11
+ tinyfy-0.1.0.dist-info/licenses/LICENSE,sha256=H3Yr_8eANaPDcTjGbL7xXzW3VTuJTZG0bekjXjLHzkY,1071
12
+ tinyfy-0.1.0.dist-info/METADATA,sha256=2egKDClocqgNnD8A6R1tK3KlSMsrb93sSJ7lBPSw8tg,4915
13
+ tinyfy-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
14
+ tinyfy-0.1.0.dist-info/entry_points.txt,sha256=hurkb74K60_I16X2hfQHl1GGKkib6UvBh1DF4FFBVP0,66
15
+ tinyfy-0.1.0.dist-info/top_level.txt,sha256=SRWK7J4GDdvRCxDTIPK10Jy2KgcikaAt9ImQsbzx8QM,7
16
+ tinyfy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ tiny = tinyfy.cli:main
3
+ tinyfy = tinyfy.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tinyfy authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ tinyfy