# Image Toolkit — convert, resize, strip metadata, inspect (`praestat/image-toolkit`) Actor

Batch image jobs without a design tool: convert between JPEG, PNG, WebP and AVIF, resize to a target width without upscaling, strip EXIF/GPS/XMP for privacy, or read image metadata. One dataset item per image, files in the key-value store, per-image pricing.

- **URL**: https://apify.com/praestat/image-toolkit.md
- **Developed by:** [praestat media](https://apify.com/praestat) (community)
- **Categories:** Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 50.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 image processeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Image Toolkit — convert, resize, strip metadata, inspect

The routine image jobs of a data pipeline, without Photoshop, ImageMagick or an external service.
Everything runs inside the actor container: convert between JPEG, PNG, WebP and AVIF, resize to a
target width, strip EXIF/GPS/XMP for privacy, or just read the metadata. Results come back as
structured dataset items plus downloadable files in the key-value store.

### Operations

| `operation` | What it does | Output |
|---|---|---|
| `convert` | Change the file format (`jpg`, `png`, `webp`, `avif`); transparency is flattened onto `background` for JPEG | new image file |
| `resize` | Scale to `targetWidth`, keep the aspect ratio, optional `maxHeight` ceiling; **never upscales** | new image file |
| `strip_metadata` | Rebuild the image without EXIF, GPS, XMP, ICC or DPI — lossless PNG by default, JPEG with `jpegQuality` | new image file |
| `info` | Format, mode, dimensions, EXIF/GPS/profile flags, DPI, frame count, megapixels, aspect ratio, print size at 300 dpi — and for geotagged photos the coordinates plus ready-made links to OpenStreetMap, Google Maps and Bing Maps — no image data, no file written | metadata only |

Supported inputs: whatever the Pillow build reads — JPEG, PNG, WebP, AVIF, GIF (including animated),
TIFF, BMP, ICO, PPM. Unreadable files produce a failed item with a clear message instead of
aborting the run.

### Input example

```json
{
  "operation": "convert",
  "imageUrls": ["https://example.com/photo.png"],
  "targetFormat": "jpg",
  "jpegQuality": 82,
  "background": "#ffffff"
}
```

```json
{
  "operation": "resize",
  "imageUrls": ["https://example.com/hero.jpg", "https://example.com/thumb.jpg"],
  "targetWidth": 1600,
  "maxHeight": 2000
}
```

```json
{
  "operation": "strip_metadata",
  "imageUrls": ["https://example.com/upload/selfie.jpg"],
  "stripFormat": "png"
}
```

```json
{
  "operation": "info",
  "imageUrls": ["https://example.com/photo.jpg"]
}
```

### Output

**One dataset item per image.** Every image gets its own item, plus a file in the key-value store.

```json
{
  "operation": "convert",
  "source": "photo.png",
  "status": "ok",
  "sourceFormat": "PNG",
  "sourceMode": "RGBA",
  "format": "JPEG",
  "width": 800,
  "height": 600,
  "mode": "RGB",
  "bytesBefore": 51234,
  "bytesAfter": 18420,
  "savedPercent": 64.05,
  "file": "out/photo-converted.jpg",
  "filename": "photo-converted.jpg",
  "key": "file-photo-converted.jpg",
  "note": null,
  "error": null
}
```

#### Why one item per image instead of one summary item

The alternative — a single item with a list of all files — was considered and rejected:

- Batches fail **partially**: one truncated download, one 404, one CMYK oddity. With one item per
  image, `status` and `error` are first-class fields; `GET /items?status=failed` or Apify's dataset
  filter shows exactly what to retry. With a summary item the caller has to parse a nested list to
  discover the same thing.
- Dataset views, sorting and CSV export work per row. A nested file list breaks all three.
- Apify's dataset UI, webhooks and `apify call` consumers expect a flat item per processed unit;
  this is the shape that plays well with `pushData`-style pipelines.
- Cost: 10,000 images produce 10,000 small items. That is well inside the platform limits and is
  the normal shape for actors that process lists.

The trade-off is more rows for very large jobs. Each row stays small (a few hundred bytes, no image
data), and `key` plus `file` always point at the actual file.

Generated files are also stored in the default key-value store under the prefix `file-`, so a
follow-up actor or your own script can pick them up directly. Sub-paths are flattened with `_`
(`file-out_photo-converted.jpg`), which is why the store schema can collect them as one collection.
Downloaded inputs live under `_src/` and are deliberately **not** stored.

### Pricing (pay per event)

| Event | When | Price |
|---|---|---|
| `apify-actor-start` | once per run and per GB of memory (2 GB default) | $0.005 |
| `image-processed` | per image whose item has `status: "ok"` — convert, resize, strip_metadata and info alike | $0.001 |

A 1,000-image conversion therefore costs about $1.01 including the start event.
Failed images are not charged: they appear in the dataset, but the counter only adds `status: "ok"`.

`byte-saved` is deliberately **not** an event. The saving depends entirely on the input material
(a 12 MP photo re-encoded at quality 82 shrinks by 90 %, a 5 KB icon by 2 %), so billing on it would
be unpredictable for you and unverifiable for us. Storage and compute are already covered by
`image-processed`.

### Limits and honest notes

- **No upscaling, ever.** If an image is narrower than `targetWidth`, the original file is copied
  unchanged (byte-identical) and the item carries `resized: false` plus a note. If you need a
  minimum size, filter on `resized` afterwards.
- **Animated images:** GIF and animated WebP are processed as their **first frame**. The item says
  `animated: true` and how many frames the source had (`frames`). There is no frame/animation output.
- **Transparency and JPEG:** transparent areas are flattened onto `background` (default `#ffffff`).
  Left to itself Pillow would write black, which is almost never what you want for a product shot.
- **CMYK:** CMYK JPEGs are converted to RGB. There is no colour management — no ICC transform to
  sRGB — so a print proof can look slightly different after conversion. `convert` keeps an embedded
  ICC profile, but it does not apply it.
- **16-bit and float images:** reduced to 8 bit per channel (`65535 → 255`); the item flags
  `bitDepthReduced: true`. PNG output is 8 bit, not 16 bit.
- **EXIF orientation:** `resize` and `strip_metadata` bake the rotation into the pixels, so the
  result stands upright even after the EXIF tag is gone. `convert` leaves the pixels as stored and
  keeps the orientation tag, so viewers still rotate it — but a naive consumer that ignores EXIF
  will see it sideways.
- **AVIF** needs a Pillow build with libavif (Pillow ≥ 11.2; the official wheels include it). If the
  build lacks it, the run stops with a clear message instead of writing unusable files. AVIF
  encoding is noticeably slower than JPEG.
- **`strip_metadata` is a privacy tool, not a forensic one.** It removes EXIF (including GPS and
  camera data), XMP, ICC and DPI by rebuilding the pixel buffer and saving without metadata. It does
  not scrub additional images hidden in MPO/MPF containers, steganography or proprietary chunks; PNG
  output (the default) cannot carry EXIF at all.
- **`info` decodes the file** to validate it (Pillow's `load()`), even though it only reports headers.
  A truncated file is therefore reported as failed rather than described as healthy.
- **No colour management, no HDR/wide-gamut handling, no 32-bit output.** Resizing uses Lanczos.
- **Source access:** public URLs or local paths. No authenticated fetches, cookies or signed S3
  requests. On Apify, local paths refer to the container filesystem (e.g. a file you fetched in an
  earlier step) — in practice use URLs.
- **Duplicate file names** from different sources get `-2`, `-3` suffixes in the output folder;
  nothing is silently overwritten.
- Very large images (100 MP+) need more memory than the default 2 GB run; raise the memory setting.
  The `apify-actor-start` price scales with it.

### Reporting a problem

Please use the **Issues** tab on the Actor page instead of email. The report then sits with the
right Actor, together with the input and the run it came from — usually exactly what is missing to
reproduce it. Issues are read and answered there.

### What happens to your data

Input files and results stay in your run's storage on Apify. Apify keeps the ten most recent runs of
an Actor indefinitely; older runs and their default storages are deleted under the platform's
retention rules. I do not export or archive your files anywhere else, and the Actor never sends them
to a third-party service.

### Local development

```bash
python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python -m pytest tests -q
.venv/bin/python samples/make_samples.py
echo '{"operation":"resize","imageUrls":["samples/transparent-rgba.png"],"targetWidth":400}' > /tmp/in.json
.venv/bin/python -m src --input /tmp/in.json --outdir out --push
```

The CLI path is the same code as the actor run, so what passes locally behaves identically on Apify.
The tests need no network and no Apify token; they generate their own test images.

# Actor input Schema

## `operation` (type: `string`):

convert changes the file format, resize scales to a target width, strip_metadata removes EXIF/GPS/XMP, info only reports metadata and writes no file.

## `imageUrls` (type: `array`):

Image URLs or local paths. One dataset item is produced per image; a broken file fails its own item and does not stop the batch.

## `targetFormat` (type: `string`):

Output format for convert. jpg has no transparency, png is lossless, webp is small and keeps transparency, avif is smallest but needs AVIF support in the build (otherwise the run stops with a clear message).

## `targetWidth` (type: `integer`):

Desired width for resize. The height follows the aspect ratio. Images that are already narrower are left unchanged — never upscaled.

## `maxHeight` (type: `integer`):

Optional ceiling for portrait images: if the height would exceed this value, the image is scaled down further and becomes narrower than targetWidth. 0 = no limit.

## `jpegQuality` (type: `integer`):

Quality for lossy output (convert, resize, strip_metadata with jpg). 82 is a good default; 95 for archival, 60 for thumbnails. Ignored for PNG.

## `stripFormat` (type: `string`):

png (default) is lossless and cannot carry EXIF; jpg re-encodes with jpegQuality, which is smaller but lossy.

## `background` (type: `string`):

Colour that replaces transparent areas when the target format has no alpha channel (JPEG). Accepts #rrggbb, #rgb or a CSS colour name. Default white — Pillow would otherwise flatten onto black.

## Actor input object example

```json
{
  "operation": "convert",
  "imageUrls": [
    "https://example.com/photo.jpg"
  ],
  "targetFormat": "jpg",
  "targetWidth": 1200,
  "maxHeight": 0,
  "jpegQuality": 82,
  "stripFormat": "png",
  "background": "#ffffff"
}
```

# Actor output Schema

## `results` (type: `string`):

One item per image: status, format, dimensions, byte sizes, metadata flags and the produced file key.

## `files` (type: `string`):

Converted, resized or cleaned images, stored under the prefix file-.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "imageUrls": [
        "https://example.com/photo.jpg"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("praestat/image-toolkit").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "imageUrls": ["https://example.com/photo.jpg"] }

# Run the Actor and wait for it to finish
run = client.actor("praestat/image-toolkit").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "imageUrls": [
    "https://example.com/photo.jpg"
  ]
}' |
apify call praestat/image-toolkit --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,praestat/image-toolkit"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/wTG0sXIFV2Z59abY3/builds/CFtKBohbTWzxtcRaa/openapi.json
