ecompress 2.0.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.
- ecompress/__init__.py +57 -0
- ecompress/__main__.py +10 -0
- ecompress/api.py +332 -0
- ecompress/backends/__init__.py +19 -0
- ecompress/backends/audio.py +354 -0
- ecompress/backends/base.py +189 -0
- ecompress/backends/image.py +385 -0
- ecompress/backends/pdf.py +448 -0
- ecompress/backends/video.py +400 -0
- ecompress/cli.py +271 -0
- ecompress/detect.py +256 -0
- ecompress/diagnostics.py +176 -0
- ecompress/errors.py +98 -0
- ecompress/ffmpeg.py +409 -0
- ecompress/naming.py +164 -0
- ecompress/process.py +114 -0
- ecompress/py.typed +0 -0
- ecompress/quality.py +191 -0
- ecompress/reporting.py +59 -0
- ecompress/result.py +139 -0
- ecompress/search.py +230 -0
- ecompress/units.py +172 -0
- ecompress/validation.py +163 -0
- ecompress-2.0.0.dist-info/METADATA +537 -0
- ecompress-2.0.0.dist-info/RECORD +28 -0
- ecompress-2.0.0.dist-info/WHEEL +4 -0
- ecompress-2.0.0.dist-info/entry_points.txt +2 -0
- ecompress-2.0.0.dist-info/licenses/LICENSE +21 -0
ecompress/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Compress any file below a target size in MB, with one simple command.
|
|
2
|
+
|
|
3
|
+
ecompress "video.mp4" 50
|
|
4
|
+
|
|
5
|
+
The target is a hard ceiling: the produced file is measured on disk and
|
|
6
|
+
re-parsed by an independent reader before the run is called a success.
|
|
7
|
+
|
|
8
|
+
Python usage::
|
|
9
|
+
|
|
10
|
+
from ecompress import compress
|
|
11
|
+
|
|
12
|
+
result = compress(r"D:\\Videos\\movie.mp4", 50)
|
|
13
|
+
print(result.output_path)
|
|
14
|
+
print(result.output_size_mb) # always < 50
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ecompress.api import compress
|
|
20
|
+
from ecompress.errors import (
|
|
21
|
+
CompressError,
|
|
22
|
+
InputFileError,
|
|
23
|
+
InvalidTargetError,
|
|
24
|
+
MissingDependencyError,
|
|
25
|
+
OutputValidationError,
|
|
26
|
+
TargetNotAchievableError,
|
|
27
|
+
ToolExecutionError,
|
|
28
|
+
UnsupportedFormatError,
|
|
29
|
+
)
|
|
30
|
+
from ecompress.reporting import ConsoleReporter, NullReporter, Reporter
|
|
31
|
+
from ecompress.result import Attempt, CompressionResult, MediaType
|
|
32
|
+
from ecompress.units import BYTES_PER_MB, bytes_to_mb, format_size, mb_to_bytes
|
|
33
|
+
|
|
34
|
+
__version__ = "2.0.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"BYTES_PER_MB",
|
|
38
|
+
"Attempt",
|
|
39
|
+
"CompressError",
|
|
40
|
+
"CompressionResult",
|
|
41
|
+
"ConsoleReporter",
|
|
42
|
+
"InputFileError",
|
|
43
|
+
"InvalidTargetError",
|
|
44
|
+
"MediaType",
|
|
45
|
+
"MissingDependencyError",
|
|
46
|
+
"NullReporter",
|
|
47
|
+
"OutputValidationError",
|
|
48
|
+
"Reporter",
|
|
49
|
+
"TargetNotAchievableError",
|
|
50
|
+
"ToolExecutionError",
|
|
51
|
+
"UnsupportedFormatError",
|
|
52
|
+
"__version__",
|
|
53
|
+
"bytes_to_mb",
|
|
54
|
+
"compress",
|
|
55
|
+
"format_size",
|
|
56
|
+
"mb_to_bytes",
|
|
57
|
+
]
|
ecompress/__main__.py
ADDED
ecompress/api.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""The public :func:`compress` entry point.
|
|
2
|
+
|
|
3
|
+
Orchestration order:
|
|
4
|
+
|
|
5
|
+
1. Validate the input path and the requested target.
|
|
6
|
+
2. Short-circuit when the file is already small enough.
|
|
7
|
+
3. Detect the media type from content (falling back to the extension).
|
|
8
|
+
4. Run the matching backend inside a private scratch directory.
|
|
9
|
+
5. Re-validate the winning candidate and move it to its final name.
|
|
10
|
+
|
|
11
|
+
The returned :class:`~compress.result.CompressionResult` always satisfies
|
|
12
|
+
``output_size_bytes < target_size_bytes``. When that cannot be achieved a
|
|
13
|
+
:class:`~compress.errors.TargetNotAchievableError` is raised - a result object
|
|
14
|
+
is never used to report a missed target.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import shutil
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ecompress.backends.audio import AudioBackend
|
|
25
|
+
from ecompress.backends.base import Backend, BackendOutcome, Job
|
|
26
|
+
from ecompress.backends.image import ImageBackend
|
|
27
|
+
from ecompress.backends.pdf import PdfBackend
|
|
28
|
+
from ecompress.backends.video import VideoBackend
|
|
29
|
+
from ecompress.detect import detect_media_type
|
|
30
|
+
from ecompress.errors import (
|
|
31
|
+
InputFileError,
|
|
32
|
+
InvalidTargetError,
|
|
33
|
+
OutputValidationError,
|
|
34
|
+
TargetNotAchievableError,
|
|
35
|
+
)
|
|
36
|
+
from ecompress.naming import ReservedPath, reserve_output_path
|
|
37
|
+
from ecompress.reporting import NullReporter, Reporter
|
|
38
|
+
from ecompress.result import CompressionResult, MediaType
|
|
39
|
+
from ecompress.units import SizeRange, format_size, parse_size_range
|
|
40
|
+
from ecompress.validation import validate_output
|
|
41
|
+
|
|
42
|
+
__all__ = ["compress"]
|
|
43
|
+
|
|
44
|
+
#: Aim this far below the ceiling so ordinary variation cannot cross it.
|
|
45
|
+
#: 3% of the target, capped at 2 MB - for a 50 MB request we aim at ~48.5 MB.
|
|
46
|
+
SAFETY_MARGIN_RATIO = 0.03
|
|
47
|
+
SAFETY_MARGIN_CAP = 2_000_000
|
|
48
|
+
|
|
49
|
+
_BACKENDS = {
|
|
50
|
+
MediaType.IMAGE: ImageBackend,
|
|
51
|
+
MediaType.VIDEO: VideoBackend,
|
|
52
|
+
MediaType.AUDIO: AudioBackend,
|
|
53
|
+
MediaType.PDF: PdfBackend,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compress(
|
|
58
|
+
path: str | Path,
|
|
59
|
+
target_mb: int | float | str | tuple[float, float] | list[float],
|
|
60
|
+
*,
|
|
61
|
+
min_mb: int | float | str | None = None,
|
|
62
|
+
output_path: str | Path | None = None,
|
|
63
|
+
reporter: Reporter | None = None,
|
|
64
|
+
overwrite: bool = False,
|
|
65
|
+
timeout: float | None = None,
|
|
66
|
+
) -> CompressionResult:
|
|
67
|
+
"""Compress ``path`` so the result is strictly smaller than ``target_mb``.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
path: the file to compress. It is never modified or overwritten.
|
|
71
|
+
target_mb: the maximum size of the output in decimal megabytes
|
|
72
|
+
(``1 MB == 1_000_000 bytes``). Fractional values are allowed. A
|
|
73
|
+
range may be given instead - ``"40-50"``, ``"[40,50]"`` or
|
|
74
|
+
``(40, 50)`` - meaning "below 50 MB but not below 40 MB".
|
|
75
|
+
min_mb: a quality floor, as an alternative to passing a range. The
|
|
76
|
+
search keeps raising quality until the output reaches it, so the
|
|
77
|
+
budget is used rather than undershot. It is a goal, not a hard
|
|
78
|
+
constraint: when even maximum quality lands below it (the source
|
|
79
|
+
is simply small, and inflating it would add bytes without adding
|
|
80
|
+
quality) the result is still returned, with an explanatory note.
|
|
81
|
+
output_path: write here instead of the automatic
|
|
82
|
+
``<name>_compressed<ext>`` next to the input.
|
|
83
|
+
reporter: receives progress events; defaults to silence.
|
|
84
|
+
overwrite: allow ``output_path`` to replace an existing file.
|
|
85
|
+
timeout: seconds allowed for each individual encoder invocation.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A :class:`~compress.result.CompressionResult` whose
|
|
89
|
+
``output_size_bytes`` is strictly below ``target_size_bytes``. When the
|
|
90
|
+
input was already small enough, ``skipped`` is ``True`` and
|
|
91
|
+
``output_path`` is the untouched original.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
InputFileError: the input is missing, empty, unreadable or a directory.
|
|
95
|
+
InvalidTargetError: ``target_mb`` is not a positive, finite number.
|
|
96
|
+
UnsupportedFormatError: the file type has no backend.
|
|
97
|
+
MissingDependencyError: FFmpeg is needed but not installed.
|
|
98
|
+
TargetNotAchievableError: no valid output could be brought under the
|
|
99
|
+
target.
|
|
100
|
+
"""
|
|
101
|
+
reporter = reporter or NullReporter()
|
|
102
|
+
input_path = _validate_input(path)
|
|
103
|
+
size_range = _validate_target(target_mb, min_mb)
|
|
104
|
+
target_bytes = size_range.maximum
|
|
105
|
+
input_size = input_path.stat().st_size
|
|
106
|
+
|
|
107
|
+
reporter.step(f"Original size: {format_size(input_size)}")
|
|
108
|
+
if size_range.minimum is None:
|
|
109
|
+
reporter.step(f"Target size: {format_size(target_bytes)}")
|
|
110
|
+
else:
|
|
111
|
+
reporter.step(
|
|
112
|
+
f"Target size: {format_size(size_range.minimum)} to {format_size(target_bytes)}"
|
|
113
|
+
)
|
|
114
|
+
reporter.step("")
|
|
115
|
+
|
|
116
|
+
# Check the file type before the shortcut below, so an unsupported file is
|
|
117
|
+
# rejected rather than being waved through as "already small enough".
|
|
118
|
+
# ``allow_probe=False`` keeps this step free of any FFmpeg dependency.
|
|
119
|
+
if input_size < target_bytes:
|
|
120
|
+
notes = ["The file is already below the requested target; it was left untouched."]
|
|
121
|
+
if size_range.minimum is not None and input_size < size_range.minimum:
|
|
122
|
+
notes.append(
|
|
123
|
+
f"It is also below the {format_size(size_range.minimum)} minimum. "
|
|
124
|
+
"Padding it out would add bytes without adding quality, so it was "
|
|
125
|
+
"left as it is."
|
|
126
|
+
)
|
|
127
|
+
return CompressionResult(
|
|
128
|
+
input_path=input_path,
|
|
129
|
+
output_path=input_path,
|
|
130
|
+
input_size_bytes=input_size,
|
|
131
|
+
output_size_bytes=input_size,
|
|
132
|
+
target_size_bytes=target_bytes,
|
|
133
|
+
min_size_bytes=size_range.minimum,
|
|
134
|
+
media_type=detect_media_type(input_path, allow_probe=False).media_type,
|
|
135
|
+
attempts=[],
|
|
136
|
+
target_achieved=True,
|
|
137
|
+
skipped=True,
|
|
138
|
+
backend="none",
|
|
139
|
+
notes=notes,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
reporter.step("Detecting media type...")
|
|
143
|
+
detection = detect_media_type(input_path)
|
|
144
|
+
for note in detection.notes:
|
|
145
|
+
reporter.note(note)
|
|
146
|
+
|
|
147
|
+
backend: Backend = _BACKENDS[detection.media_type]()
|
|
148
|
+
aim_bytes = _aim_bytes(target_bytes)
|
|
149
|
+
|
|
150
|
+
workdir, cleanup_root = _make_workdir(input_path, output_path)
|
|
151
|
+
try:
|
|
152
|
+
job = Job(
|
|
153
|
+
input_path=input_path,
|
|
154
|
+
input_size_bytes=input_size,
|
|
155
|
+
target_bytes=target_bytes,
|
|
156
|
+
aim_bytes=aim_bytes,
|
|
157
|
+
detection=detection,
|
|
158
|
+
workdir=workdir,
|
|
159
|
+
reporter=reporter,
|
|
160
|
+
timeout=timeout,
|
|
161
|
+
min_bytes=size_range.minimum,
|
|
162
|
+
)
|
|
163
|
+
outcome = backend.compress(job)
|
|
164
|
+
|
|
165
|
+
if not outcome.achieved or outcome.best_path is None:
|
|
166
|
+
raise TargetNotAchievableError(
|
|
167
|
+
input_path=input_path,
|
|
168
|
+
target_bytes=target_bytes,
|
|
169
|
+
smallest_valid_bytes=outcome.smallest_valid_bytes,
|
|
170
|
+
attempts=len(outcome.attempts),
|
|
171
|
+
detail=outcome.detail,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
final = _finalise(
|
|
175
|
+
input_path=input_path,
|
|
176
|
+
outcome=outcome,
|
|
177
|
+
detection_extension=detection.extension,
|
|
178
|
+
explicit_output=output_path,
|
|
179
|
+
overwrite=overwrite,
|
|
180
|
+
target_bytes=target_bytes,
|
|
181
|
+
media_type=detection.media_type,
|
|
182
|
+
)
|
|
183
|
+
final_size = final.stat().st_size
|
|
184
|
+
notes = list(outcome.notes)
|
|
185
|
+
if size_range.minimum is not None and final_size < size_range.minimum:
|
|
186
|
+
notes.append(
|
|
187
|
+
f"The result came out below the {format_size(size_range.minimum)} "
|
|
188
|
+
"minimum: this is the largest valid output the source and format "
|
|
189
|
+
"can produce, and padding it would add bytes without adding quality."
|
|
190
|
+
)
|
|
191
|
+
return CompressionResult(
|
|
192
|
+
input_path=input_path,
|
|
193
|
+
output_path=final,
|
|
194
|
+
input_size_bytes=input_size,
|
|
195
|
+
output_size_bytes=final_size,
|
|
196
|
+
target_size_bytes=target_bytes,
|
|
197
|
+
min_size_bytes=size_range.minimum,
|
|
198
|
+
media_type=detection.media_type,
|
|
199
|
+
attempts=list(outcome.attempts),
|
|
200
|
+
target_achieved=True,
|
|
201
|
+
skipped=False,
|
|
202
|
+
format_changed=outcome.format_changed,
|
|
203
|
+
backend=backend.name,
|
|
204
|
+
notes=notes,
|
|
205
|
+
)
|
|
206
|
+
finally:
|
|
207
|
+
shutil.rmtree(cleanup_root, ignore_errors=True)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# -- input validation ------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _validate_input(path: str | Path) -> Path:
|
|
214
|
+
if isinstance(path, Path):
|
|
215
|
+
candidate = path
|
|
216
|
+
elif isinstance(path, str):
|
|
217
|
+
if not path.strip():
|
|
218
|
+
raise InputFileError("No input file was given.")
|
|
219
|
+
candidate = Path(path)
|
|
220
|
+
else:
|
|
221
|
+
raise InputFileError(f"Expected a file path, got {type(path).__name__}.")
|
|
222
|
+
|
|
223
|
+
candidate = candidate.expanduser()
|
|
224
|
+
try:
|
|
225
|
+
exists = candidate.exists()
|
|
226
|
+
except OSError as exc:
|
|
227
|
+
raise InputFileError(f"Could not access {candidate}: {exc}") from exc
|
|
228
|
+
|
|
229
|
+
if not exists:
|
|
230
|
+
raise InputFileError(f"File not found: {candidate}\n\nCheck the path and try again.")
|
|
231
|
+
if candidate.is_dir():
|
|
232
|
+
raise InputFileError(
|
|
233
|
+
f"{candidate} is a folder, not a file.\n\nGive the path to a single file."
|
|
234
|
+
)
|
|
235
|
+
if not candidate.is_file():
|
|
236
|
+
raise InputFileError(f"{candidate} is not a regular file.")
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
size = candidate.stat().st_size
|
|
240
|
+
except OSError as exc:
|
|
241
|
+
raise InputFileError(f"Could not read {candidate}: {exc}") from exc
|
|
242
|
+
if size == 0:
|
|
243
|
+
raise InputFileError(f"{candidate} is empty; there is nothing to compress.")
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
with candidate.open("rb") as handle:
|
|
247
|
+
handle.read(1)
|
|
248
|
+
except OSError as exc:
|
|
249
|
+
raise InputFileError(f"No permission to read {candidate}: {exc}") from exc
|
|
250
|
+
|
|
251
|
+
return candidate
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _validate_target(target_mb: object, min_mb: object = None) -> SizeRange:
|
|
255
|
+
# bool is an int subclass; reject it explicitly.
|
|
256
|
+
if isinstance(target_mb, bool) or isinstance(min_mb, bool):
|
|
257
|
+
raise InvalidTargetError("The target size must be a number of megabytes, e.g. 50.")
|
|
258
|
+
try:
|
|
259
|
+
return parse_size_range(target_mb, minimum=min_mb) # type: ignore[arg-type]
|
|
260
|
+
except ValueError as exc:
|
|
261
|
+
raise InvalidTargetError(
|
|
262
|
+
f"{exc}\n\nGive the target as a number of megabytes, for example:\n"
|
|
263
|
+
' ecompress "video.mp4" 50 below 50 MB\n'
|
|
264
|
+
' ecompress "video.mp4" 40-50 below 50 MB but not under 40 MB'
|
|
265
|
+
) from exc
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _aim_bytes(target_bytes: int) -> int:
|
|
269
|
+
margin = min(int(target_bytes * SAFETY_MARGIN_RATIO), SAFETY_MARGIN_CAP)
|
|
270
|
+
return max(target_bytes - margin, 1)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# -- workspace and finalisation --------------------------------------------
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _make_workdir(input_path: Path, output_path: str | Path | None) -> tuple[Path, Path]:
|
|
277
|
+
"""A private scratch directory, preferably on the destination volume.
|
|
278
|
+
|
|
279
|
+
Keeping candidates on the same volume as the final file turns the last step
|
|
280
|
+
into a rename instead of a multi-gigabyte copy.
|
|
281
|
+
"""
|
|
282
|
+
preferred = Path(output_path).expanduser().parent if output_path else input_path.parent
|
|
283
|
+
for base in (preferred, None):
|
|
284
|
+
try:
|
|
285
|
+
root = Path(tempfile.mkdtemp(prefix=".compress-", dir=base))
|
|
286
|
+
except (OSError, ValueError):
|
|
287
|
+
continue
|
|
288
|
+
return root, root
|
|
289
|
+
raise InputFileError( # pragma: no cover - only if even the system temp fails
|
|
290
|
+
"Could not create a temporary working directory."
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _finalise(
|
|
295
|
+
*,
|
|
296
|
+
input_path: Path,
|
|
297
|
+
outcome: BackendOutcome,
|
|
298
|
+
detection_extension: str,
|
|
299
|
+
explicit_output: str | Path | None,
|
|
300
|
+
overwrite: bool,
|
|
301
|
+
target_bytes: int,
|
|
302
|
+
media_type: MediaType,
|
|
303
|
+
) -> Path:
|
|
304
|
+
"""Re-check the winner, then move it into place under a unique name."""
|
|
305
|
+
best = outcome.best_path
|
|
306
|
+
assert best is not None # noqa: S101 - guarded by the caller
|
|
307
|
+
|
|
308
|
+
extension = outcome.output_extension or detection_extension
|
|
309
|
+
reserved: ReservedPath = reserve_output_path(
|
|
310
|
+
input_path,
|
|
311
|
+
extension=extension,
|
|
312
|
+
explicit=Path(explicit_output) if explicit_output is not None else None,
|
|
313
|
+
overwrite=overwrite,
|
|
314
|
+
)
|
|
315
|
+
try:
|
|
316
|
+
shutil.move(str(best), str(reserved.path))
|
|
317
|
+
except OSError as exc:
|
|
318
|
+
reserved.release()
|
|
319
|
+
raise InputFileError(f"Could not write the output file: {exc}") from exc
|
|
320
|
+
|
|
321
|
+
# Final independent check on the file that will actually be handed over.
|
|
322
|
+
final_size = reserved.path.stat().st_size
|
|
323
|
+
report = validate_output(reserved.path, media_type)
|
|
324
|
+
if not report.valid or final_size >= target_bytes or final_size == 0:
|
|
325
|
+
reason = report.reason if not report.valid else f"final size {format_size(final_size)}"
|
|
326
|
+
with contextlib.suppress(OSError):
|
|
327
|
+
reserved.path.unlink()
|
|
328
|
+
raise OutputValidationError(
|
|
329
|
+
"The compressed file failed its final check and was discarded "
|
|
330
|
+
f"({reason}). Nothing was written."
|
|
331
|
+
)
|
|
332
|
+
return reserved.path
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Compression backends, one per media family."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ecompress.backends.audio import AudioBackend
|
|
6
|
+
from ecompress.backends.base import Backend, BackendOutcome, Job
|
|
7
|
+
from ecompress.backends.image import ImageBackend
|
|
8
|
+
from ecompress.backends.pdf import PdfBackend
|
|
9
|
+
from ecompress.backends.video import VideoBackend
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AudioBackend",
|
|
13
|
+
"Backend",
|
|
14
|
+
"BackendOutcome",
|
|
15
|
+
"ImageBackend",
|
|
16
|
+
"Job",
|
|
17
|
+
"PdfBackend",
|
|
18
|
+
"VideoBackend",
|
|
19
|
+
]
|