# Subtitle Toolkit — convert, shift, merge, split, check (`praestat/subtitle-toolkit`) Actor

Everyday subtitle jobs without transcription: convert SRT, WebVTT and ASS/SSA, shift timestamps, merge files with per-file offsets, split by cue count or seconds, and get a readability report with CPS outliers. Structured output, per-cue pricing.

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

## Pricing

from $2.00 / 1,000 subtitle 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

## Subtitle Toolkit — convert, shift, merge, split, check

One Apify Actor for the everyday subtitle jobs. It does **not** transcribe: you bring an existing
subtitle file and get a cleaned, converted, shifted, merged or split file back, plus structured
numbers about it. Everything runs inside the container — no ffmpeg, no external service, no network
calls after the input file has been fetched.

Formats: **SubRip (SRT)**, **WebVTT (VTT)**, **Advanced SubStation Alpha (ASS/SSA)**.

### Operations

| `operation` | What it does | Output |
|---|---|---|
| `convert` | Switch format: SRT ↔ WebVTT ↔ ASS/SSA; `same` rewrites the file in its own format (normalised, UTF-8) | one subtitle file |
| `shift` | Add seconds to every timestamp, negative allowed, never below 0; optionally only cues inside a time window | one subtitle file |
| `merge` | Append files in the given order with an optional per-file offset, then sort by start time | one subtitle file |
| `split` | Cut into parts by cue count or by seconds | one file per part |
| `report` | Cue count, duration, overlaps, line lengths, characters per second with outliers, plus language and speech-style metrics — see below | `report.json` + flat metrics |

### Input examples

```json
{
  "operation": "convert",
  "subtitleUrls": ["https://example.com/episode.srt"],
  "targetFormat": "vtt"
}
```

```json
{
  "operation": "shift",
  "subtitleUrls": ["https://example.com/episode.srt"],
  "shiftSeconds": -1.5,
  "windowFromSec": 600,
  "windowToSec": 900
}
```

```json
{
  "operation": "merge",
  "subtitleUrls": ["https://example.com/part1.srt", "https://example.com/part2.srt"],
  "mergeOffsets": [0, 742.4],
  "outputFormat": "ass"
}
```

```json
{
  "operation": "split",
  "subtitleUrls": ["https://example.com/movie.srt"],
  "splitEverySeconds": 600
}
```

```json
{
  "operation": "report",
  "subtitleUrls": ["https://example.com/episode.vtt"],
  "cpsThreshold": 20
}
```

### Output

Every run pushes exactly one item to the dataset:

```json
{
  "operation": "convert",
  "fmt": "vtt",
  "sourceFormat": "srt",
  "encoding": "utf-8",
  "cueCount": 412,
  "durationSec": 1450.2,
  "file": "out/episode.vtt"
}
```

`report` returns the metrics flat, so they read well in the table view:

```json
{
  "operation": "report",
  "cueCount": 412,
  "durationSec": 1450.2,
  "overlappingCues": 3,
  "multiLineCues": 11,
  "cuesWithoutEnd": 0,
  "totalChars": 24118,
  "averageCps": 16.63,
  "maxCps": 31.4,
  "cpsOutliers": [
    {"index": 87, "cps": 31.4, "chars": 94, "durationSec": 2.99, "text": "…"}
  ],
  "cpsOutlierCount": 1,
  "longestLine": "…",
  "longestLineChars": 94,
  "file": "out/report.json"
}
```

`split` adds `parts` and a `files` list with one entry per part. All produced files land in the
default key-value store under the prefix `file-`, so a follow-up run or your own script can pick
them up directly.

### What `report` gives you — and what it cannot

`report` reads one subtitle file and changes nothing. It writes `report.json` into the key-value
store and returns exactly the same numbers flat in the dataset item, so the table view is already
the report. Real output for `samples/sample.srt` (abridged):

```json
{
  "language": "de", "languageName": "Deutsch", "languageConfidence": 1.0,
  "languages": [{"code": "de", "name": "Deutsch", "probability": 1.0, "matches": 5}],
  "cueCount": 6, "durationSec": 17.9,
  "overlappingCues": 1, "multiLineCues": 1, "cuesWithoutEnd": 0,
  "totalChars": 321, "averageCps": 17.73, "maxCps": 48.57,
  "cpsOutliers": [{"index": 4, "cps": 48.57, "chars": 136, "durationSec": 2.8, "text": "Diese Zeile …"}],
  "cpsOutlierCount": 1, "longestLineChars": 136,
  "style": {"wordsTotal": 50, "wordsPerMinute": 167.6, "avgWordsPerCue": 8.3,
            "avgCharsPerLine": 31.0, "avgLinesPerCue": 1.5, "questionCues": 0,
            "exclamationCues": 0, "uppercaseShare": 0.02,
            "speakerLabels": [], "speakerLabelledCues": 0,
            "formalAddress": 0, "informalAddress": 0, "register": null}
}
```

**Timing and layout** — `cueCount`, `durationSec` (first start to last end), `overlappingCues`
(a cue that starts before the latest end seen so far), `multiLineCues` (more than two lines),
`cuesWithoutEnd`, `longestLine` / `longestLineChars`.

**Readability** — `totalChars`, `averageCps` (all characters including spaces over the summed cue
durations), `maxCps`, and `cpsOutliers` with `cps`, `chars`, `durationSec` and the full cue text, so
you can jump straight to the line that is too fast. The threshold is an input field
(`cpsThreshold`, default 20 characters per second).

**Language** — `language` / `languageName` / `languageConfidence` plus `languages`, a ranking of up
to three candidates. The ranking counts matched function words (the, der, le …) rather than trusting
a single probability, which is what makes it usable on short subtitle text. Below 20 letters of real
text the Actor claims nothing and returns `null` — a guess would be worse than no answer.

**Style** — `wordsTotal`, `wordsPerMinute` (words over the whole span, pauses included),
`avgWordsPerCue`, `avgCharsPerLine`, `avgLinesPerCue`, `questionCues`, `exclamationCues`,
`uppercaseShare`, recognised speaker labels (`MUELLER:` at the start of a line, up to 25 characters,
upper case) and — for German — formal vs. informal address (`formalAddress`, `informalAddress`,
`register`).

What it cannot do:

- **It cannot tell whether the subtitles are in sync with the video.** All timings come from the
  file; nothing is compared with the actual audio or picture. Drift, wrong offsets and missing cues
  stay invisible.
- **The language is a statistical guess.** `languageConfidence: 1.0` is what langdetect reports for
  a clear case; it is not a calibrated probability. Short cues, names, songs and mixed-language
  files can be wrong. The function-word ranking covers German, English, French, Spanish, Italian,
  Dutch, Portuguese, Polish, Swedish and Danish — other languages fall back to langdetect's top
  three without a match count.
- **Overlap counting is coarse.** It is one counter against the running maximum end, not a list of
  pairs, so it tells you *that* something overlaps, not which cue collides with which.
- **German `Sie` is ambiguous.** "Sie" is both formal *you* and *she/they*, so `formalAddress` can
  overcount on third-person text. `register` is therefore a hint, not a verdict.
- **`wordsPerMinute` is not speech rate.** It divides words by the file's whole span, pauses
  included. 100–160 wpm is a rule of thumb for normal speech, not a limit this Actor enforces.
- **Guidelines are reported, not enforced.** Netflix-style rules (≈42 characters per line, at most
  two lines) show up as `avgCharsPerLine` and `multiLineCues`; the Actor does not rewrite anything.
  `report` never changes the file — use `convert` or `shift` for that.

### Encoding

Sources are read tolerantly: UTF-8 with or without BOM first, then Windows-1252 (the usual case for
subtitles written on Windows, umlauts survive), then Latin-1. The encoding that was actually used is
reported in the item as `encoding`. Output is always UTF-8 without BOM.

### Pricing (pay per event)

| Event | When | Price |
|---|---|---|
| `apify-actor-start` | once per run and per GB of memory (1 GB default) | $0.005 |
| `cue-processed` | per cue in the result | $0.0002 |
| `file-converted` | per produced file (a split into 4 parts counts 4) | $0.002 |

A 30-minute episode with roughly 400 cues therefore costs about $0.09 for a conversion, and the
same for a report. Long files scale with cue count, not with wall-clock time.

### Limits and honest notes

- **No transcription.** There is no speech recognition in this Actor — if you have audio or video
  only, this is the wrong tool.
- Cue text is treated as plain text. SRT and WebVTT styling markup (`<i>`, `<b>`, `{\an8}`) is kept
  as characters in the text but not interpreted; ASS override blocks (`{\i1}` …) are removed when
  reading ASS, because they would otherwise end up as literal text in SRT and WebVTT.
- ASS → ASS keeps the original `[Script Info]`, `[V4+ Styles]` and `[Events]` header, including all
  styles. SRT/VTT → ASS produces one `Default` style — a full style authoring tool this is not.
- `merge` takes the header (and thus the styles) of the **first** file. Cues from later ASS files
  that reference other style names fall back to `Default`.
- Overlapping cues are reported, not repaired. Shifting can only clamp at 0; it cannot decide which
  cue should win.
- Cues without an end time stay without one when read and are written with zero duration; they get
  no CPS value, because characters per second needs a duration.
- `split` never cuts a cue in half. With `splitEverySeconds` a part can therefore be slightly longer
  than requested.
- Output file names come from the source name (`episode.srt` → `episode.vtt`), or from
  `outputName`. Special characters are kept, paths are stripped.

### 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. Include the file if you can (or the cue that breaks); subtitle files are small.

### 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
echo '{"operation":"convert","subtitleUrls":["samples/sample.srt"],"targetFormat":"vtt"}' > /tmp/in.json
.venv/bin/python -m src --input /tmp/in.json --outdir out --push
```

`samples/` holds a small SRT, VTT and ASS file with the same six cues — one overlapping cue and
one CPS outlier included — for trying the Actor out.

The CLI path is the same code as the actor run, so what passes locally behaves identically on Apify.

# Actor input Schema

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

Was mit den Untertiteln passieren soll.

## `subtitleUrls` (type: `array`):

URLs oder lokale Pfade zu Untertiteldateien. 'merge' verarbeitet alle in dieser Reihenfolge, alle anderen Operationen die erste Datei.

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

Nur für convert: Zielformat. 'same' schreibt die Datei im Quellformat neu (normalisiert) — nützlich, um Kodierung und Leerzeilen zu bereinigen.

## `outputFormat` (type: `string`):

Format der Ergebnisdatei für shift, merge und split. 'same' behält das Quellformat (bei merge: das der ersten Datei).

## `shiftSeconds` (type: `number`):

Nur für shift: Sekunden, die auf alle Zeitstempel addiert werden. Negative Werte sind erlaubt; kein Cue beginnt vor 0, betroffene Cues werden gekappt.

## `windowFromSec` (type: `number`):

Nur für shift: nur Cues, die ab dieser Sekunde starten, werden verschoben. 0 = keine untere Grenze.

## `windowToSec` (type: `number`):

Nur für shift: nur Cues, die bis zu dieser Sekunde starten, werden verschoben. 0 = keine obere Grenze.

## `mergeOffsets` (type: `array`):

Nur für merge: eine Zahl je Datei in derselben Reihenfolge wie subtitleUrls, z. B. \[0, 12.5]. Fehlende Werte bedeuten 0. Danach wird nach Startzeit sortiert.

## `splitEveryCues` (type: `integer`):

Nur für split: alle N Cues ein Teil. 0 = diese Variante nicht verwenden.

## `splitEverySeconds` (type: `number`):

Nur für split: ein Teil, sobald die Grenze in Sekunden erreicht ist. Cues werden nie zerschnitten, deshalb kann ein Teil länger werden als N Sekunden. 0 = diese Variante nicht verwenden.

## `cpsThreshold` (type: `number`):

Nur für report: Zeichen pro Sekunde, ab der ein Cue als Ausreißer gilt. 20 ist die übliche Lesbarkeitsgrenze für Untertitel.

## `outputName` (type: `string`):

Optionaler Dateiname ohne Endung für das Ergebnis, z. B. kunde-2024. Leer = Name der Quelldatei.

## Actor input object example

```json
{
  "operation": "convert",
  "subtitleUrls": [
    "https://example.com/sample.srt"
  ],
  "targetFormat": "srt",
  "outputFormat": "same",
  "shiftSeconds": 0,
  "windowFromSec": 0,
  "windowToSec": 0,
  "mergeOffsets": [],
  "splitEveryCues": 0,
  "splitEverySeconds": 0,
  "cpsThreshold": 20,
  "outputName": ""
}
```

# Actor output Schema

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

One item per run: operation, cue count, duration, output format and — for report — overlap, line-length and CPS metrics.

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

Converted, shifted, merged and split subtitle files plus the report JSON, 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 = {
    "subtitleUrls": [
        "https://example.com/sample.srt"
    ],
    "mergeOffsets": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("praestat/subtitle-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 = {
    "subtitleUrls": ["https://example.com/sample.srt"],
    "mergeOffsets": [],
}

# Run the Actor and wait for it to finish
run = client.actor("praestat/subtitle-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 '{
  "subtitleUrls": [
    "https://example.com/sample.srt"
  ],
  "mergeOffsets": []
}' |
apify call praestat/subtitle-toolkit --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,praestat/subtitle-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/Y0Bg9tNYahonLudZ0/builds/ZnlV85ifYjnu6rdWE/openapi.json
