diff --git a/README.md b/README.md index 5451a29..eebe500 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,89 @@ +
+ # image-and-video-compressor -Compress images and videos from the command line or from Node. Parallel by default, honest about what it did, and safe to drive from a script. +**Compress images and videos from the command line or from Node.** +Parallel by default, honest about what it did, and safe to drive from a script. + +[![npm version](https://img.shields.io/npm/v/image-and-video-compressor?color=cb3837&logo=npm&logoColor=white)](https://www.npmjs.com/package/image-and-video-compressor) +[![npm downloads](https://img.shields.io/npm/dm/image-and-video-compressor?color=cb3837)](https://www.npmjs.com/package/image-and-video-compressor) +[![CI](https://github.com/rohanpoudel2/image_video_compressor/actions/workflows/ci.yml/badge.svg)](https://github.com/rohanpoudel2/image_video_compressor/actions/workflows/ci.yml) +[![node](https://img.shields.io/node/v/image-and-video-compressor?logo=node.js&logoColor=white)](https://nodejs.org) +[![types](https://img.shields.io/npm/types/image-and-video-compressor?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![license](https://img.shields.io/npm/l/image-and-video-compressor)](./LICENSE) + +
+ +--- ```bash -npm install -g image-and-video-compressor -imgvidcompress ./photos +npx image-and-video-compressor ./photos ``` +That is the whole quick start. No install, no config file, no flags. + ``` imgvidcompress compressing images and videos - ✓ hero.jpg 4.2 MB → 388 KB −91.0% - ✓ team-photo.jpg 2.8 MB → 241 KB −91.4% - ✓ promo.mp4 18.4 MB → 5.1 MB −72.3% - ○ icon.png skipped — compressing made it bigger, original kept + ✓ graphic-flat.png 976 KB → 64 KB −93.5% + ✓ hero-huge.jpg 330 KB → 148 KB −55.3% + ✓ portrait-tall.jpg 55 KB → 20 KB −62.9% + ✓ clip-1080p-30fps.mp4 4.3 MB → 2.4 MB −44.9% + ✓ clip-720p-60fps.mp4 4.4 MB → 2.0 MB −55.0% + ○ clip-480p-24fps.mov skipped — compressing made it bigger, original kept ────────────────────────────────────────────────────────── - Total 25.4 MB → 5.7 MB −19.7 MB (77.6%) - 3 compressed · 1 skipped · 12.4s + Total 14 MB → 7.1 MB −7.4 MB (51.1%) + 15 compressed · 1 skipped · 1.2s ``` -## Why v2 - -v1 worked, but only on the happy path. v2 is a rewrite in TypeScript that fixes the things v1 got wrong and adds what a compressor should have had from the start. - -| | v1 | v2 | -| ---------------------------- | ------------------------------------------------------ | ---------------------------------- | -| Output piped to a file or CI | **crashed** (`clearLine is not a function`) | works | -| `--output=.webm` | produced an unplayable file (H.264 in WebM) | VP9/Opus, or AV1 | -| Video quality | `crf = 100 - quality`, often outside the codec's range | mapped onto each codec's own scale | -| Frame rate | forced to 30fps, wrecking 24fps and 60fps sources | preserved unless you ask | -| 500 files | 500 simultaneous processes | bounded worker pool | -| One corrupt file | killed the whole batch | reported, batch continues | -| Bytes saved | never shown | per file and in total | -| Use from a script | `require()` ran the CLI | real library with types | - -Full detail in [MIGRATION.md](./MIGRATION.md). +Real output from `npm run samples && imgvidcompress samples --recursive`. Your numbers will differ; these are the project's own test fixtures. + +## What you get + +- **One command for both.** Images and videos in the same run, detected by extension and, where that is not enough, by content. +- **Parallel, but bounded.** A worker pool sized to your CPU count, not one process per file. +- **It never lies about the result.** If compressing made a file bigger, the original is kept and the file is reported as skipped. +- **Safe in a pipeline.** `--json` puts exactly one document on stdout and every human-facing byte on stderr. Distinct exit codes, stable error codes. +- **A real library.** Typed ESM and CJS exports, `AbortSignal` support, progress events. Importing it does not run the CLI. +- **Honest about your machine.** `formats` reports what your sharp and ffmpeg builds can actually do, verified by encoding, not by a hardcoded list. + +## Contents + +- [Install](#install) +- [CLI](#cli) + - [Commands](#commands) + - [Options](#options) + - [Examples](#examples) +- [Formats](#formats) +- [File detection](#file-detection) +- [Node API](#node-api) + - [Functions](#functions) + - [Options reference](#options-reference) + - [Results](#results) + - [Progress and cancellation](#progress-and-cancellation) + - [Branded types](#branded-types) +- [Automation and AI agents](#automation-and-ai-agents) + - [The `--json` contract](#the---json-contract) + - [Exit codes](#exit-codes) + - [Error codes](#error-codes) +- [Recipes](#recipes) +- [FAQ](#faq) +- [Coming from v1](#coming-from-v1) +- [Contributing](#contributing) +- [License](#license) ## Install ```bash -npm install -g image-and-video-compressor +npm install -g image-and-video-compressor # CLI everywhere +npm install image-and-video-compressor # as a dependency +npx image-and-video-compressor ./photos # no install at all ``` -Requires **Node 20.11+**. Images work out of the box. Video needs `ffmpeg` on your `PATH`: +Requires **Node 20.11 or newer**. + +Images work out of the box — [sharp](https://sharp.pixelplumbing.com/) ships prebuilt binaries. Video needs `ffmpeg` on your `PATH`: ```bash brew install ffmpeg # macOS @@ -51,9 +91,9 @@ sudo apt install ffmpeg # Debian/Ubuntu winget install Gyan.FFmpeg # Windows ``` -Or point at an existing binary with `--ffmpeg-path` / `FFMPEG_PATH`. +Or point at a binary you already have with `--ffmpeg-path` or the `FFMPEG_PATH` environment variable. Without ffmpeg the image half still works; only video is unavailable, and the error says so. -## Usage +## CLI ```bash imgvidcompress [options] # auto-detects images and videos @@ -62,7 +102,60 @@ imgvidcompress video # videos only imgvidcompress formats # what this build supports ``` -Paths can be files or directories, and your shell's globs work as usual. +Paths can be files or directories, and your shell's globs work as usual. Output goes to `/compressed` unless you say otherwise, and a recursive run mirrors the input directory structure. + +### Commands + +| Command | What it does | +| --------------------------------- | ------------------------------------------------------------------------------------ | +| `imgvidcompress ` | Compress every image and video found. The default command. | +| `imgvidcompress image ` | Images only. Ignores video files entirely. | +| `imgvidcompress video ` | Videos only. | +| `imgvidcompress formats` | Print this machine's real capabilities. Add `--json` for a machine-readable version. | +| `imgvidcompress --help` | Full usage, including per-command help such as `image --help`. | + +### Options + +Shared by `run`, `image` and `video`: + +| Option | Default | Description | +| ----------------------- | --------------------- | ------------------------------------------------------------------ | +| `-q, --quality <1-100>` | `75` | Higher is better looking. Mapped onto each codec's own scale. | +| `-t, --to ` | `.webp` / `.mp4` | Output format. Run `formats` to see what is available. | +| `-o, --out ` | `/compressed` | Output directory. Structure is mirrored under it. | +| `-r, --recursive` | off | Descend into subdirectories. | +| `-c, --concurrency ` | see below | Files processed at once. | +| `--max-width ` | — | Shrink anything wider. Never enlarges. | +| `--max-height ` | — | Shrink anything taller. Never enlarges. | +| `--overwrite` | off | Replace existing output files instead of skipping them. | +| `--dry-run` | off | Report the plan, write nothing. | +| `--json` | off | One JSON document on stdout; human output moves to stderr. | +| `--quiet` | off | Suppress all non-error output. | +| `--no-color` | — | Disable coloured output. | +| `--no-skip-larger` | — | Write the output even when it ends up bigger than the source. | + + Images default to WebP, except that a source already in a modern format keeps it — an `.avif` input stays AVIF and a `.webp` input stays WebP rather than being pointlessly transcoded. Videos default to `.mp4`. + +Concurrency defaults differ by media kind, because the two libraries behave differently: images get `min(cores, 8)` since sharp parallelises internally through libvips, videos get `cores / 4` since a single ffmpeg process already saturates several cores. Stacking more mostly buys contention. + +**Image options** + +| Option | Default | Description | +| ------------------ | ------- | ---------------------------------------------- | +| `--keep-metadata` | off | Preserve EXIF and ICC instead of stripping it. | +| `--no-auto-rotate` | — | Do not apply EXIF orientation. | + +**Video options** + +| Option | Default | Description | +| ---------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `--codec ` | per container | `libx264`, `libx265`, `libvpx-vp9`, `libvpx`, `libsvtav1`, `libaom-av1`, `mpeg4`, `libtheora`. Must be legal for the container. | +| `--audio-codec ` | per container | `aac`, `libopus`, or `copy` to pass the original track through. | +| `--fps ` | source rate | Cap the frame rate. Left alone by default. | +| `--preset ` | per codec | Encoder speed/efficiency tradeoff. Codec-specific. | +| `--ffmpeg-path ` | `$FFMPEG_PATH`, then `PATH` | Path to the ffmpeg binary. | + +### Examples ```bash # Everything in a folder, into ./photos/compressed @@ -79,38 +172,24 @@ imgvidcompress hero.png banner.jpg # See the plan without touching anything imgvidcompress ./assets --dry-run -``` - -### Options -| Option | Description | -| ---------------------------------------- | ----------------------------------------------------------- | -| `-q, --quality <1-100>` | Higher is better looking. Default `75` | -| `-t, --to ` | Output format. Default `.webp` for images, `.mp4` for video | -| `-o, --out ` | Output directory. Default `/compressed` | -| `-r, --recursive` | Descend into subdirectories | -| `-c, --concurrency ` | Files at once. Defaults to a value based on CPU count | -| `--max-width ` / `--max-height ` | Shrink to fit, never enlarge | -| `--overwrite` | Replace existing output files | -| `--dry-run` | Report the plan, write nothing | -| `--json` | JSON report on stdout | -| `--quiet` / `--no-color` | Quieten or decolour the output | -| `--no-skip-larger` | Write output even when it ends up bigger than the source | +# Web-ready video: VP9 in WebM, keeping the source frame rate +imgvidcompress video ./clips --to .webm --quality 55 -**Images:** `--keep-metadata`, `--no-auto-rotate` - -**Video:** `--codec`, `--audio-codec`, `--fps`, `--preset`, `--ffmpeg-path` +# Re-run over a folder you have already processed +imgvidcompress ./photos --overwrite +``` -### Formats +## Formats -Support is bounded by what your sharp and ffmpeg builds can actually do — not by a list in this README. Both are build-dependent, so run: +Support is bounded by what your sharp and ffmpeg builds can actually do, not by a list in this README. Both are build-dependent, so ask the tool: ```bash imgvidcompress formats # human-readable imgvidcompress formats --json # machine-readable ``` -**Images.** The writable set is established by encoding a single pixel with each candidate at startup, which is the only check that cannot be wrong: `sharp.format` claims support for formats that then fail at encode time. JPEG 2000, JPEG XL and HEIC are all commonly missing, and a stock macOS build has none of the three. Reads cover everything libvips decodes — SVG, PDF, camera raw — which is a strictly wider set than writes. +**Images.** The writable set is established at startup by encoding a single pixel with each candidate, which is the only check that cannot be wrong: `sharp.format` claims support for formats that then fail at encode time. JPEG 2000, JPEG XL and HEIC are all commonly missing, and a stock macOS build has none of the three. Reads cover everything libvips decodes, which is a strictly wider set than writes — SVG, PDF, and camera raw (`.arw`, `.cr2`, `.nef`, `.dng`, and friends) come in but never go out. **Videos.** Two tiers. Curated containers get a verified codec matrix and tuned per-codec flags: @@ -123,17 +202,21 @@ imgvidcompress formats --json # machine-readable | `.avi` | H.264, MPEG-4 | MP3 | | `.ogv` | Theora | Vorbis, Opus | -Beyond those, **any muxer your ffmpeg reports is a valid `--to`** — a stock Homebrew build carries 184 of them, with around 100 video encoders available to `--codec`. For an uncurated container the muxer's own defaults apply, which are muxable by construction. +Beyond those, **any muxer your ffmpeg reports is a valid `--to`**. A stock Homebrew build carries 184 of them, with around 100 video encoders available to `--codec`. For an uncurated container the muxer's own defaults apply, which are muxable by construction. -WebM genuinely cannot carry H.264. For curated containers that restriction is enforced by the compiler, not a runtime check: an impossible pairing is a build error in library code and a clear message on the CLI. +WebM genuinely cannot carry H.264. For curated containers that restriction is enforced by the compiler rather than by a runtime check: an impossible pairing is a build error in library code and a clear message on the CLI. Quality maps onto each codec's own scale and direction — CRF counts down, Theora's `-q:v` counts up, MPEG-4 uses qscale — so `--quality 70` means the same thing regardless of codec. -### File detection +Streams that the target container cannot carry are reported rather than dropped in silence. Losing a commentary track or a subtitle without a word is worse than refusing outright, because nobody notices until they need it. -Extension first, since it is free and almost always right. Content sniffing takes over where a name genuinely cannot answer: a file with no extension, an unknown one, or an ISO container where only the brand bytes distinguish an AVIF image from an MP4 video. A merely mislabelled file needs no special handling — sharp and ffmpeg both detect their input's real format. When a decode does fail, the file is sniffed so the error can say "this .jpg is actually an MP4" instead of "unrecognised data". +## File detection -## Programmatic API +Extension first, since it is free and almost always right. Content sniffing takes over where a name genuinely cannot answer: a file with no extension, an unknown one, or an ISO container where only the brand bytes distinguish an AVIF image from an MP4 video. + +A merely mislabelled file needs no special handling, because sharp and ffmpeg both detect their input's real format. When a decode does fail, the file is sniffed so the error can say "this .jpg is actually an MP4" instead of "unrecognised data". + +## Node API ```ts import { compressImages, toQuality, toPixels } from "image-and-video-compressor"; @@ -155,18 +238,133 @@ for (const result of report.results) { } ``` -`compressImages`, `compressVideos` and `compress` always resolve. Per-file problems appear as `status: "failed"` inside `results`; only setup errors (no inputs, a bad option) reject. Pass an `AbortSignal` to cancel. +Ships as ESM and CommonJS with bundled type declarations. `require()` and `import` both work. + +### Functions + +| Function | Signature | +| ------------------------ | --------------------------------------------------------------------------------------------------------------- | +| `compress` | `(paths: string[], options?: CompressOptions) => Promise` — images and videos, auto-detected | +| `compressImages` | `(paths: string[], options?: ImageOptions) => Promise` | +| `compressVideos` | `(paths: string[], options?: VideoOptions) => Promise` | +| `discoverFiles` | Walk paths and classify what is there, without compressing | +| `imageCapabilities` | What this sharp build can genuinely read and write | +| `ffmpegCapabilities` | Muxers, demuxers and encoders this ffmpeg reports | +| `resolveFfmpeg` | Locate the ffmpeg binary and its version | +| `probeMedia` | ffprobe a file into typed stream information | +| `sniff` / `sniffFile` | Identify a file by content rather than name | +| `toQuality` / `toPixels` | Validating constructors for the branded scalar types | -Quality and pixel values go through `toQuality()` and `toPixels()`, which validate the range and return branded types. A raw `number` will not typecheck — that is what stops a 1-100 quality being fed to a flag that expects 0-51. +The three compression functions **always resolve**. Per-file problems appear as `status: "failed"` inside `results`; only setup errors — no inputs, a bad option, ffmpeg missing — reject with a `CompressorError`. -## Scripting and AI agents +Format registries (`VIDEO_CONTAINERS`, `VIDEO_CODECS`, `AUDIO_CODECS`, `CURATED_IMAGE_FORMATS`) and the codec-matrix predicates (`isCodecAllowedIn`, `isVideoContainer`, `qualityToCrf`, …) are exported too, so a caller can validate a combination before running one. -`--json` writes exactly one JSON document to stdout. Every human-facing byte — banner, progress, summary — goes to stderr, so the stream is always safe to parse. +### Options reference + +Shared by every entry point: + +| Option | Type | Default | +| ------------- | -------------------------------- | --------------------- | +| `quality` | `Quality` | `75` | +| `outDir` | `string` | `/compressed` | +| `recursive` | `boolean` | `false` | +| `concurrency` | `number` | per media kind | +| `overwrite` | `boolean` | `false` | +| `dryRun` | `boolean` | `false` | +| `skipLarger` | `boolean` | `true` | +| `resize` | `ResizeOptions` | — | +| `signal` | `AbortSignal` | — | +| `onProgress` | `(event: ProgressEvent) => void` | — | + +`ImageOptions` adds `to`, `autoRotate` (default `true`) and `keepMetadata` (default `false`). +`VideoOptions` adds `to`, `videoCodec`, `audioCodec`, `fps`, `preset` and `ffmpegPath`. +`CompressOptions` is the union used by `compress()`, where `to` widens to accept either kind. + +### Results + +Every file produces one entry in a discriminated union, so a run always yields a complete record of what happened: + +```ts +type JobResult = + | ({ status: "compressed" } & CompressedResult) + | ({ status: "skipped" } & SkippedResult) + | ({ status: "failed" } & FailedResult); +``` + +All three carry `kind`, `inputPath` and `outputPath`. Beyond that: + +| Status | Additional fields | +| -------------- | ---------------------------------------------------------------------------------- | +| `"compressed"` | `inputBytes`, `outputBytes`, `savedBytes`, `savedRatio`, `durationMs`, `warnings?` | +| `"skipped"` | `inputBytes`, `reason`, `warnings?` | +| `"failed"` | `error: { code, message, detail? }` | + +`savedBytes` is positive when space was saved and negative if the output grew. `savedRatio` is the fraction of the original removed, 0-1. + +`isCompressed`, `isSkipped` and `isFailed` are exported as type guards, so consumers never hand-check `status`: + +```ts +import { isFailed, isCompressed } from "image-and-video-compressor"; + +const failures = report.results.filter(isFailed); +const bytes = report.results + .filter(isCompressed) + .reduce((sum, r) => sum + r.savedBytes, 0); +``` + +`reason` on a skip is one of `output-larger-than-input`, `output-exists` or `dry-run`. + +### Progress and cancellation + +```ts +const controller = new AbortController(); +setTimeout(() => controller.abort(), 30_000); + +await compress(["./assets"], { + signal: controller.signal, + onProgress: (event) => { + switch (event.type) { + case "run-start": + console.log(`${event.total} files`); + break; + case "job-start": + break; + case "job-progress": + console.log(event.job.inputPath, event.ratio); + break; + case "job-done": + console.log(event.result.status); + break; + } + }, +}); +``` + +`run-start` arrives once, after discovery and planning, because the total is not knowable before then. `job-progress` carries a 0-1 ratio and is video-only; images complete atomically. + +### Branded types + +Quality and pixel values go through `toQuality()` and `toPixels()`, which validate the range and return branded types. A raw `number` will not typecheck: + +```ts +compressImages(["./photos"], { quality: 80 }); // ✗ compile error +compressImages(["./photos"], { quality: toQuality(80) }); // ✓ +``` + +This is what stops a 1-100 quality value being fed to a flag that expects a 0-51 CRF. `toQuality(150)` throws a `RangeValidationError` at the boundary rather than producing a quietly wrong encode. The project's own `npm run typecheck` asserts these failures with `@ts-expect-error`. + +## Automation and AI agents + +The tool is built to be driven by something that is not a person: a build script, a CI job, or a coding agent. + +### The `--json` contract + +`--json` writes exactly one JSON document to stdout. Every human-facing byte — banner, progress, summary, warnings — goes to stderr, so the stream is always safe to parse. ```bash imgvidcompress ./assets --json | jq '.summary.savedBytes' -imgvidcompress ./assets --dry-run --json | jq '.results[].outputPath' -imgvidcompress formats --json | jq '.video.output' +imgvidcompress ./assets --dry-run --json | jq -r '.results[].outputPath' +imgvidcompress formats --json | jq '.video.curated[].extension' ``` ```jsonc @@ -175,24 +373,37 @@ imgvidcompress formats --json | jq '.video.output' "ok": true, "dryRun": false, "summary": { - "totalFiles": 3, - "compressed": 3, + "totalFiles": 2, + "compressed": 2, + "skipped": 0, "failed": 0, - "savedBytes": 20658176, - "savedRatio": 0.776, + "inputBytes": 64931, + "outputBytes": 33106, + "savedBytes": 31825, + "savedRatio": 0.4901, + "durationMs": 55, }, "results": [ { "status": "compressed", - "inputPath": "…/hero.jpg", - "outputPath": "…/compressed/hero.webp", - "savedRatio": 0.91, + "kind": "image", + "inputPath": "…/photo-medium.jpg", + "outputPath": "…/compressed/photo-medium.webp", + "inputBytes": 44331, + "outputBytes": 17902, + "savedBytes": 26429, + "savedRatio": 0.5962, + "durationMs": 46, }, ], } ``` -Exit codes: +`schemaVersion` is bumped on any breaking change to these shapes, so a consumer can pin behaviour instead of guessing whether a field still means what it did last release. Errors use the same envelope with `ok: false` and an `error` object. + +`imgvidcompress formats --json` reports the installation's actual capabilities, including which codecs each container accepts. A caller can discover the constraints instead of hardcoding assumptions that are wrong on some machines. + +### Exit codes | Code | Meaning | | ---- | ----------------------------------------------------------- | @@ -202,13 +413,95 @@ Exit codes: | `3` | ffmpeg not found | | `4` | No matching input files | -Error codes in JSON (`FFMPEG_NOT_FOUND`, `ENCODE_FAILED`, `UNSUPPORTED_FORMAT`, …) are stable across releases; the human-readable messages are not. Branch on the code. +### Error codes + +`FFMPEG_NOT_FOUND`, `FFMPEG_FAILED`, `DECODE_FAILED`, `ENCODE_FAILED`, `UNSUPPORTED_FORMAT`, `INPUT_NOT_FOUND`, `PERMISSION_DENIED`, `INVALID_OPTION`, `NO_INPUT_FILES`, `ABORTED`, `UNKNOWN`. -`imgvidcompress formats --json` reports this build's actual capabilities, including which codecs each container accepts, so a caller can discover the constraints instead of hardcoding them. +These are stable across releases. The human-readable messages are not — branch on the code. -## Deprecated v1 commands +## Recipes -The v1 commands still work and still write to `optimised_images/` and `optimised_videos/`. They print a deprecation notice and will be removed in v3. +**Compress assets as part of a build** + +```json +{ + "scripts": { + "build:assets": "imgvidcompress image ./src/assets --recursive --to .webp --out ./dist/assets" + } +} +``` + +**Fail CI when an asset is too big to ship** + +```bash +imgvidcompress ./public/img --dry-run --json \ + | jq -e '[.results[].inputBytes] | max < 500000' \ + || { echo "An image exceeds 500 KB"; exit 1; } +``` + +**Report the saving without writing anything** + +```bash +imgvidcompress ./assets --dry-run --json | jq '.summary' +``` + +**Use it from a build script** + +```ts +import { compressImages, toQuality, isFailed } from "image-and-video-compressor"; + +const report = await compressImages(["./src/assets"], { + quality: toQuality(82), + to: ".webp", + recursive: true, + outDir: "./dist/assets", +}); + +const failed = report.results.filter(isFailed); +if (failed.length > 0) process.exit(1); +``` + +**Check what the machine supports before offering a format** + +```ts +import { imageCapabilities } from "image-and-video-compressor"; + +const caps = await imageCapabilities(); +const canAvif = caps.writable.some((f) => f.extensions.includes(".avif")); +``` + +## FAQ + +**A file came out bigger. Why is it in the output as "skipped"?** +Because the original was kept. Some inputs — small PNGs, already-optimised WebP, low-detail graphics — do not get smaller when re-encoded. Rather than write a worse file, the tool keeps the original and tells you. Pass `--no-skip-larger` if you want the bigger output anyway, usually because you need the format conversion more than the bytes. + +**Where did my files go?** +`/compressed`, next to the input. Use `--out` to choose. A recursive run mirrors the input tree under the output directory rather than flattening it. + +**Does it overwrite my originals?** +Never. Output always goes to a separate directory, and an existing output file is skipped unless you pass `--overwrite`. + +**`FFMPEG_NOT_FOUND`, but ffmpeg is installed.** +It is not on the `PATH` this process sees, which is common with GUI-launched editors and some CI images. Pass `--ffmpeg-path /full/path/to/ffmpeg`, or set `FFMPEG_PATH` (and `FFPROBE_PATH`, which is derived from it by default). Confirm with `imgvidcompress formats`, which prints the resolved ffmpeg version; `formats --json` carries the full path in `video.ffmpeg`. + +**`--to .webm` rejected my `--codec libx264`.** +WebM cannot carry H.264. Use `libvpx-vp9` or `libsvtav1`, or target `.mp4` instead. `imgvidcompress formats` lists the legal codecs per container for your build. + +**Why is `.avif` or `.jxl` missing from `formats`?** +Your sharp build was compiled without it. The list is produced by actually encoding a pixel with each candidate, so it reflects the binary you have rather than what the package hopes is there. + +**Can I compress in place?** +Not directly, and deliberately so. Write to a directory, verify, then move. A crash mid-encode over the original is not recoverable. + +**Is quality 75 the same for JPEG and for H.265?** +Yes, in the sense that matters: the value is mapped onto each codec's own scale and direction. You do not need to remember that CRF counts down while Theora counts up. + +**Does it work when output is piped or redirected?** +Yes. The progress display degrades to plain append-only lines when stdout is not a TTY, which is also the nicer CI log. (v1 crashed here; see below.) + +## Coming from v1 + +**Nothing you have scripted breaks today.** The v1 commands still work, still write to `optimised_images/` and `optimised_videos/`, and still take the same flags. They print a deprecation notice and will be removed in v3. ```bash # Still works — prints a notice @@ -218,9 +511,24 @@ imgvidcompress optimise:image --loadFolder='./photos' --quality=40 --output='.we imgvidcompress image ./photos --quality 40 --to .webp ``` -Nothing you have scripted needs to change today. They run on the v2 pipeline, so they pick up every fix above while keeping v1's output layout and defaults. +They run on the v2 pipeline, so they pick up every fix below while keeping v1's output layout and defaults. + +| | v1 | v2 | +| ---------------------------- | ------------------------------------------------------ | ---------------------------------- | +| Output piped to a file or CI | **crashed** (`clearLine is not a function`) | works | +| `--output=.webm` | produced an unplayable file (H.264 in WebM) | VP9/Opus, or AV1 | +| Video quality | `crf = 100 - quality`, often outside the codec's range | mapped onto each codec's own scale | +| Frame rate | forced to 30fps, wrecking 24fps and 60fps sources | preserved unless you ask | +| 500 files | 500 simultaneous processes | bounded worker pool | +| One corrupt file | killed the whole batch | reported, batch continues | +| Bytes saved | never shown | per file and in total | +| Use from a script | `require()` ran the CLI | real library with types | + +Full detail, including the exact command mapping and changed defaults, is in [MIGRATION.md](./MIGRATION.md). + +## Contributing -## Development +Issues and pull requests are welcome at [rohanpoudel2/image_video_compressor](https://github.com/rohanpoudel2/image_video_compressor). ```bash npm install @@ -228,24 +536,25 @@ npm run build npm test # builds, then runs the suite npm run typecheck # also enforces the compile-time guarantees npm run lint +npm run format:check ``` -### Trying it locally +**Trying it locally** ```bash npm run samples # downloads/generates ~14MB of test media into samples/ npm run build node dist/cli.js samples --dry-run # see the plan -node dist/cli.js image samples/images # ~68% on real photos -node dist/cli.js video samples/videos # ~85% on generated clips +node dist/cli.js image samples/images # real photos +node dist/cli.js video samples/videos # generated clips ``` `samples/` is gitignored: photos come from Lorem Picsum (Unsplash-licensed) and videos are generated by ffmpeg's synthetic sources, so both are reproducible rather than committed. -Video end-to-end tests skip themselves when ffmpeg is missing; the codec matrix, quality mapping and output parsers are pure functions and always run. `npm run typecheck` is a real test here — `test/types.test.ts` uses `@ts-expect-error` to assert that invalid container/codec pairings and unbranded quality values still fail to compile. +**How the suite is structured.** Video end-to-end tests skip themselves when ffmpeg is missing; the codec matrix, quality mapping and output parsers are pure functions and always run. `npm run typecheck` is a real test here — `test/types.test.ts` uses `@ts-expect-error` to assert that invalid container/codec pairings and unbranded quality values still fail to compile. Capability tests assert that what the tool _claims_ to support matches what the local binaries actually do, so a build missing JPEG XL or HEVC is described accurately rather than optimistically. -Capability tests assert that what the tool _claims_ to support matches what the local binaries actually do, so a build missing JPEG XL or HEVC is described accurately rather than optimistically. +CI runs on Node 20, 22 and 24 across Linux, macOS and Windows, with ffmpeg installed on each, and verifies the published tarball imports cleanly from both ESM and CommonJS. ## Credits @@ -253,4 +562,4 @@ Built on [sharp](https://sharp.pixelplumbing.com/) and [ffmpeg](https://ffmpeg.o ## License -MIT © Rohan Poudel +MIT © [Rohan Poudel](https://github.com/rohanpoudel2) diff --git a/package-lock.json b/package-lock.json index 679aa22..0ac6c06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "image-and-video-compressor", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "image-and-video-compressor", - "version": "2.0.0", + "version": "2.0.1", "license": "MIT", "dependencies": { "commander": "^15.0.0", diff --git a/package.json b/package.json index be47ba9..7c4c29b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "image-and-video-compressor", - "version": "2.0.0", - "description": "Fast, type-safe image and video compression for Node.js \u2014 parallel by default, with a colorful CLI and machine-readable JSON output for AI agents.", + "version": "2.0.1", + "description": "Fast, type-safe image and video compression for Node.js — parallel by default, with a colorful CLI and machine-readable JSON output for AI agents.", "author": "Rohan Poudel", "license": "MIT", "type": "module",