Subtitle Toolkit — convert, shift, merge, split, check
Pricing
from $2.00 / 1,000 subtitle processeds
Subtitle Toolkit — convert, shift, merge, split, check
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.
Pricing
from $2.00 / 1,000 subtitle processeds
Rating
0.0
(0)
Developer
praestat media
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
4 hours ago
Last modified
Categories
Share
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
{"operation": "convert","subtitleUrls": ["https://example.com/episode.srt"],"targetFormat": "vtt"}
{"operation": "shift","subtitleUrls": ["https://example.com/episode.srt"],"shiftSeconds": -1.5,"windowFromSec": 600,"windowToSec": 900}
{"operation": "merge","subtitleUrls": ["https://example.com/part1.srt", "https://example.com/part2.srt"],"mergeOffsets": [0, 742.4],"outputFormat": "ass"}
{"operation": "split","subtitleUrls": ["https://example.com/movie.srt"],"splitEverySeconds": 600}
{"operation": "report","subtitleUrls": ["https://example.com/episode.vtt"],"cpsThreshold": 20}
Output
Every run pushes exactly one item to the dataset:
{"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:
{"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):
{"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.0is 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
Sieis ambiguous. "Sie" is both formal you and she/they, soformalAddresscan overcount on third-person text.registeris therefore a hint, not a verdict. wordsPerMinuteis 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
avgCharsPerLineandmultiLineCues; the Actor does not rewrite anything.reportnever changes the file — useconvertorshiftfor 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 oneDefaultstyle — a full style authoring tool this is not. mergetakes the header (and thus the styles) of the first file. Cues from later ASS files that reference other style names fall back toDefault.- 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.
splitnever cuts a cue in half. WithsplitEverySecondsa part can therefore be slightly longer than requested.- Output file names come from the source name (
episode.srt→episode.vtt), or fromoutputName. 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
python -m venv .venv && .venv/bin/pip install -r requirements.txt.venv/bin/python -m pytest tests -qecho '{"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.