subtitle-timing-kit 0.0.0-stage → 0.1.0

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Standalone SRT/WebVTT subset parser with visible parse diagnostics.
6
+ - Timing and configurable readability checks, including nested overlaps.
7
+ - Nonmutating shifts with explicit negative-time clipping reports.
8
+ - Plain-text SRT/VTT serialization, an ESM API and JSON CLI.
9
+ - Static Subtitle Timing Lab and synthetic examples.
10
+
11
+ GitHub source release. npm registry publication remains pending.
@@ -0,0 +1,12 @@
1
+ # Contributing
2
+
3
+ Use Node.js 22+. No dependency installation is required for tests or the static build.
4
+
5
+ 1. Run `npm run check`.
6
+ 2. Run `npm run build:space` and open the demo using `npm run demo`.
7
+ 3. For parsing changes, include a synthetic input and its expected timestamps/diagnostics. Add tests for a concrete edge case or regression.
8
+ 4. Keep timestamps as safe integer milliseconds, preserve nonmutation, and document any newly lossy behavior.
9
+
10
+ Do not attach private subtitles, API keys, local environment files or real recordings to issues. Reproduce bugs with invented text. An overlap can be intentional; do not add a repair that silently removes or truncates dialogue.
11
+
12
+ No network calls, third-party scripts or local storage should be added to the local demo without explicitly changing its privacy description.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 j-river
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,158 @@
1
- # Temporary Holding Version
1
+ # subtitle-timing-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local SRT and WebVTT parsing, timing diagnostics, shifts, and plain-text exports.
4
+ Runs in Node.js and modern browsers with **zero runtime dependencies**.
5
+
6
+ **[Try Subtitle Timing Lab](https://huggingface.co/spaces/j-river/subtitle-timing-lab)** · [Format limits](docs/format-support.md) · [Sample files](samples/)
7
+
8
+ A subtitle file can parse successfully and still contain overlapping cues or text that flashes by too fast. This toolkit makes those problems inspectable. It does not guess which dialogue to delete, translate speech, or align words to audio.
9
+
10
+ An open-source companion resource from [SubtitlePop](https://subtitlepop.com/), whose separate [subtitle checker](https://subtitlepop.com/tools/subtitle-checker/) provides a ready-made product workflow. This standalone implementation is not a claim about the code deployed inside the commercial website.
11
+
12
+ ![Real Subtitle Timing Lab interface, with an overlapping cue and reading-speed diagnostics](https://raw.githubusercontent.com/j-river/subtitle-timing-kit/main/docs/assets/timing-check.jpg)
13
+
14
+ ## Install
15
+
16
+ Node.js **22 or newer** is required for the CLI and tests. No model, FFmpeg, Python, API key or runtime npm dependency is needed.
17
+
18
+ ```sh
19
+ npm install subtitle-timing-kit
20
+ npx subtitle-timing-kit --help
21
+ ```
22
+
23
+ ```js
24
+ import { parseSubtitles, inspectCues } from 'subtitle-timing-kit';
25
+ const parsed = parseSubtitles('1\n00:00:01,000 --> 00:00:03,000\nHello!\n');
26
+ const report = inspectCues(parsed.cues);
27
+ console.log([...parsed.issues, ...report.issues]);
28
+ ```
29
+
30
+ ## Run from GitHub
31
+
32
+ ```sh
33
+ git clone https://github.com/j-river/subtitle-timing-kit.git
34
+ cd subtitle-timing-kit
35
+ npm test
36
+ node src/cli.js inspect samples/overlap.srt
37
+ node src/cli.js convert samples/clean.srt --to vtt
38
+ node src/cli.js shift samples/clean.srt --ms 1500 --to srt
39
+ ```
40
+
41
+ CLI output goes to stdout. To save it, redirect to a **new** file:
42
+
43
+ ```sh
44
+ node src/cli.js convert samples/clean.srt --to vtt > captions.vtt
45
+ ```
46
+
47
+ Never redirect to the input filename: a shell can truncate it before the command reads it.
48
+ Warnings go to stderr. Exit codes: `0` for success (including warnings), `1` for invalid data, `2` for usage or I/O failures.
49
+
50
+ `npm pack` produces an installable local tarball. For an installed package, import from `subtitle-timing-kit` and run `subtitle-timing --help`. The package is ESM only; type declarations ship with the source.
51
+
52
+ ## API: inspect, shift, export
53
+
54
+ ```js
55
+ import {
56
+ parseSubtitles,
57
+ inspectCues,
58
+ shiftCues,
59
+ serializeSubtitles,
60
+ } from './src/index.js';
61
+
62
+ const parsed = parseSubtitles(`1
63
+ 00:00:01,000 --> 00:00:03,000
64
+ Keep the words editable.
65
+ `);
66
+ const report = inspectCues(parsed.cues);
67
+ const issues = [...parsed.issues, ...report.issues];
68
+ if (issues.some(issue => issue.severity === 'error')) {
69
+ throw new Error('Fix input errors before exporting.');
70
+ }
71
+
72
+ const shifted = shiftCues(parsed.cues, 1500);
73
+ const vtt = serializeSubtitles(shifted.cues, { format: 'vtt' });
74
+ console.log(vtt);
75
+ // WEBVTT
76
+ //
77
+ // 1
78
+ // 00:00:02.500 --> 00:00:04.500
79
+ // Keep the words editable.
80
+ ```
81
+
82
+ `parseSubtitles` returns `{ format, cues, issues }`. Cues use integer **milliseconds** and keep parsed text/identifiers/settings. Parsing errors are returned in `issues`; malformed blocks are not silently accepted. Combine parse issues with inspection issues before converting partial results. Invalid API argument types throw.
83
+
84
+ `inspectCues` returns diagnostics, per-cue metrics, and a summary. The report refers to **zero-based cue indices**; the demo labels them starting at one.
85
+
86
+ | Code | Meaning | Treatment |
87
+ | --- | --- | --- |
88
+ | `REVERSED_TIME` / `ZERO_DURATION` | Invalid duration | Error; fix before shift/export |
89
+ | `EMPTY_TEXT` | No visible text after markup removal | Error |
90
+ | `OUT_OF_ORDER` | File order differs from start-time order | Warning; sort explicitly before VTT export |
91
+ | `OVERLAP` | An interval intersects an earlier active interval | Warning; can be intentional |
92
+ | `FAST_READING` | Text exceeds the chosen characters/second limit | Editorial heuristic |
93
+ | `LONG_LINE` / `TOO_MANY_LINES` | Text exceeds chosen layout limits | Editorial heuristic |
94
+ | `VTT_BLOCK_OMITTED` / `HEADER_METADATA_OMITTED` | Metadata is outside the export subset | Loss warning |
95
+
96
+ Overlap detection handles nested intervals in `O(n log n)` time and emits at most one overlap diagnostic per later cue. It does **not** enumerate every intersecting pair.
97
+
98
+ ### Negative shifts
99
+
100
+ Negative timestamps are rejected by default. Opt into clipping when it is what you want:
101
+
102
+ ```js
103
+ const result = shiftCues(parsed.cues, -1500, { negative: 'clip' });
104
+ console.log(result.clippedIndices); // start clamped to 0, end still positive
105
+ console.log(result.droppedIndices); // end moved to 0 or earlier
106
+ ```
107
+
108
+ Input arrays and cues are not mutated. Clipping can remove dialogue, so the API reports every changed/dropped index. A global shift preserves overlaps; it does not fix clock drift.
109
+
110
+ ### Reading thresholds
111
+
112
+ ```js
113
+ inspectCues(parsed.cues, { maxLineLength: 42, maxLines: 2, maxCps: 20 });
114
+ ```
115
+
116
+ These defaults are adjustable **heuristics**, not certification against any accessibility, broadcaster or streaming-platform standard. Character counts use Unicode code points, include spaces and exclude line breaks. They are not grapheme or language-specific reading models. Markup is removed for the metrics.
117
+
118
+ ## Format limits and data loss
119
+
120
+ - SRT: numeric optional cue identifiers, `HH:MM:SS,mmm` times, multiline text; BOM and CRLF accepted. SRT timing-line extensions are rejected.
121
+ - WebVTT: a `WEBVTT` header, cue identifiers, `MM:SS.mmm` or `HH:MM:SS.mmm` times, and raw one-line cue settings. Cue settings are retained for VTT export but not fully validated.
122
+ - `NOTE`, `STYLE`, `REGION` and header metadata are omitted with parse warnings. This is a **WebVTT subset**, not a full conformance validator.
123
+ - Export defaults to plain text: angle-bracket markup, speaker tags, inline word timestamps and simple ASS override blocks are removed; common caption entities are decoded. This can change literal angle-bracket text. Keep originals.
124
+ - `{ textMode: 'preserve' }` retains the cue payload but does not translate markup between formats. It does not make WebVTT voice/region markup valid SRT. `plainText()` is **not an HTML sanitizer**; render untrusted captions using `textContent`.
125
+ - VTT export retains cue settings unless `keepSettings: false`. SRT export renumbers cues and drops VTT settings. VTT export requires start-time order; intentional overlaps remain allowed.
126
+ - Default hard limits: **2 MiB UTF-8 input/output**, **10,000 cues**. Parse options may lower the input limits.
127
+ - No ASS/SSA parser, translation, speech recognition, video rendering or media sync is included.
128
+
129
+ See [format support and conversion examples](docs/format-support.md) and the primary [WebVTT specification](https://www.w3.org/TR/webvtt1/).
130
+
131
+ ## Browser demo and Hugging Face build
132
+
133
+ ```sh
134
+ npm run build:space
135
+ npm run demo
136
+ ```
137
+
138
+ Open `http://127.0.0.1:4184/demo/index.html`. To reproduce the hosted demo, upload only `.artifacts/hf-space/` to a Hugging Face **Static** Space. Build metadata contains hashes of the source assets. The page has no upload service, remote scripts, analytics, cookies, or local storage; the hosting provider may log page requests.
139
+
140
+ Try all four synthetic samples: clean timing, overlap/reading problems, VTT markup/positioning, and invalid durations. Source edits invalidate the report and downloads until reanalysis. Shifts apply to the current working cues and accumulate; Analyze resets them to the source. The timing map shows only the first 60 cues, not video playback, and the UI lists at most 300 diagnostics.
141
+
142
+ ## Development
143
+
144
+ ```sh
145
+ npm run check
146
+ npm run build:space
147
+ npm run audit:public
148
+ ```
149
+
150
+ Tests cover timestamp boundaries, Unicode/CRLF round trips, nested overlaps, lossy format behavior, explicit clipping, resource limits, separator injection, and real CLI exit codes. All examples are synthetic and MIT licensed. See [CONTRIBUTING](CONTRIBUTING.md).
151
+
152
+ ## Product workflows
153
+
154
+ The lab starts from existing subtitle files. To generate captions from speech instead, use the separate [SubtitlePop subtitle generator](https://subtitlepop.com/subtitle-generator/). For a quick format conversion workflow, see [SRT to VTT](https://subtitlepop.com/tools/srt-to-vtt/). Those product features have their own limits and privacy terms; they are not called by this demo.
155
+
156
+ ## License
157
+
158
+ MIT. No user uploads, production credentials or commercial service code are included.
@@ -0,0 +1,54 @@
1
+ # SRT ↔ WebVTT: what changes?
2
+
3
+ This toolkit supports a deliberately small text-caption subset. Its parser is not the browser's full WebVTT state machine. The [W3C specification](https://www.w3.org/TR/webvtt1/) is the primary reference for WebVTT behavior.
4
+
5
+ ## A conversion you can inspect
6
+
7
+ Input:
8
+
9
+ ```vtt
10
+ WEBVTT
11
+
12
+ intro
13
+ 00:01.000 --> 00:03.500 align:start position:10%
14
+ <v Narrator><b>Hello</b> &amp; welcome.</v>
15
+ ```
16
+
17
+ Default SRT output:
18
+
19
+ ```srt
20
+ 1
21
+ 00:00:01,000 --> 00:00:03,500
22
+ Hello & welcome.
23
+ ```
24
+
25
+ Timing and visible words survive. The cue identifier becomes a sequential SRT index, WebVTT position settings are dropped, and speaker/bold markup is removed. A conversion is not a styled-video export.
26
+
27
+ ## Why parse and inspect are separate
28
+
29
+ `00:00:03,000 --> 00:00:02,000` has recognizable SRT timestamps, but a negative duration. Parsing preserves the evidence; inspection reports `REVERSED_TIME`. Never export partial parse results without checking parse issues first.
30
+
31
+ An overlap is different: WebVTT can intentionally show simultaneous cues. The overlap warning asks for review; it is not automatically an invalid-file error. A global offset will move both intervals and keep their intersection unchanged.
32
+
33
+ ## What this implementation does not preserve
34
+
35
+ | Item | Parse | Export |
36
+ | --- | --- | --- |
37
+ | Text cue times | Millisecond integers | Canonical comma/dot format |
38
+ | Multiline cue text | Preserved | Preserved, without blank paragraphs |
39
+ | VTT cue identifiers | Preserved | VTT retains; SRT renumbers |
40
+ | VTT cue settings | Raw text, not full syntax validation | VTT retains by default; SRT drops |
41
+ | Voice/inline timestamp markup | Kept in parsed payload | Plain mode removes; preserve mode leaves raw payload |
42
+ | NOTE / STYLE / REGION blocks | Skipped with warning | Not retained |
43
+ | VTT header metadata | Warning when present | Not retained |
44
+ | ASS/SSA | Unsupported | Unsupported |
45
+
46
+ Plain-text extraction removes angle-bracket sequences. Literal text written as `<example>` can also be removed; encode or keep the original when that matters. The extraction utility must not be used to sanitize HTML.
47
+
48
+ ## Negative offsets near zero
49
+
50
+ If `[1000, 3000]` moves by `-1500`, the default rejects the operation. With explicit clipping it becomes `[0, 1500]`. A cue whose end becomes `0` or negative is dropped and its original index reported. Never describe this as lossless repair.
51
+
52
+ ## Sample provenance
53
+
54
+ All files under `samples/` were written for this project. They contain invented instructional sentences, no real transcripts, names, private recordings or third-party media. They are distributed under the repository's MIT license.
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "subtitle-timing-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Local SRT/WebVTT parsing, timing diagnostics, shifts and plain-text subtitle exports.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
8
+ "author": "j-river",
9
+ "engines": { "node": ">=22" },
10
+ "exports": { ".": { "types": "./src/index.d.ts", "import": "./src/index.js" } },
11
+ "bin": { "subtitle-timing": "./src/cli.js" },
12
+ "files": ["src/", "samples/", "docs/format-support.md", "README.md", "LICENSE", "CONTRIBUTING.md", "CHANGELOG.md"],
13
+ "repository": { "type": "git", "url": "git+https://github.com/j-river/subtitle-timing-kit.git" },
14
+ "homepage": "https://github.com/j-river/subtitle-timing-kit#readme",
15
+ "keywords": ["subtitles", "srt", "webvtt", "captions", "timing", "subtitlepop"],
16
+ "scripts": {
17
+ "test": "node --test tests/*.test.mjs",
18
+ "check": "node --check src/index.js && node --check src/cli.js && node --check demo/app.js && npm test",
19
+ "build:space": "node scripts/build-space.mjs",
20
+ "demo": "node scripts/serve.mjs",
21
+ "audit:public": "node scripts/audit-public.mjs"
22
+ }
23
+ }
@@ -0,0 +1,11 @@
1
+ 1
2
+ 00:00:01,000 --> 00:00:03,500
3
+ Small changes make captions easy to read.
4
+
5
+ 2
6
+ 00:00:03,700 --> 00:00:06,500
7
+ Keep timecodes and text editable.
8
+
9
+ 3
10
+ 00:00:06,700 --> 00:00:09,500
11
+ Export a file your player can read.
@@ -0,0 +1,7 @@
1
+ 1
2
+ 00:00:03,000 --> 00:00:02,000
3
+ This cue ends before it starts.
4
+
5
+ 2
6
+ 00:00:04,000 --> 00:00:04,000
7
+ This cue has no duration.
@@ -0,0 +1,11 @@
1
+ WEBVTT
2
+
3
+ NOTE This comment is intentionally omitted on export.
4
+
5
+ intro
6
+ 00:01.000 --> 00:03.500 align:start position:10%
7
+ <v Narrator><b>Hello</b> &amp; welcome.</v>
8
+
9
+ outro
10
+ 00:04.000 --> 00:07.000
11
+ Subtitle files are not burned-in video captions.
@@ -0,0 +1,15 @@
1
+ 1
2
+ 00:00:00,500 --> 00:00:03,500
3
+ The first caption stays on screen.
4
+
5
+ 2
6
+ 00:00:02,800 --> 00:00:04,900
7
+ This one begins too early.
8
+
9
+ 3
10
+ 00:00:05,000 --> 00:00:05,600
11
+ This sentence is difficult to read in six tenths of a second.
12
+
13
+ 4
14
+ 00:00:06,000 --> 00:00:09,000
15
+ Fix the timing. Keep the words.
package/src/cli.js ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises';
3
+ import { parseSubtitles, inspectCues, shiftCues, serializeSubtitles, LIMITS } from './index.js';
4
+
5
+ const help = `subtitle-timing-kit
6
+ subtitle-timing inspect input.srt
7
+ subtitle-timing convert input.srt --to vtt
8
+ subtitle-timing shift input.srt --ms -1500 --to srt [--clip]
9
+
10
+ Output goes to stdout; redirect to a NEW file, never the input file.
11
+ Inspect returns JSON. Exit codes: 0=success, 1=invalid data, 2=usage/I/O.
12
+ Exports use plain text; WebVTT NOTE/STYLE/REGION blocks are omitted.
13
+ `;
14
+
15
+ try {
16
+ const [command, path, ...args] = process.argv.slice(2);
17
+ if (!command || command === '--help' || command === '-h') { console.log(help); process.exit(0); }
18
+ if (!['inspect', 'convert', 'shift'].includes(command) || !path) throw Error(help);
19
+ const flags = {};
20
+ for (let i = 0; i < args.length; i++) {
21
+ if (args[i] === '--clip') flags.clip = true;
22
+ else if (['--to', '--ms'].includes(args[i]) && args[i + 1] !== undefined) flags[args[i].slice(2)] = args[++i];
23
+ else throw Error(`Unknown or incomplete argument: ${args[i]}`);
24
+ }
25
+ if (flags.to !== undefined && !['srt', 'vtt'].includes(flags.to)) throw Error('--to must be srt or vtt.');
26
+ if (command === 'shift' && (flags.ms === undefined || !/^-?\d+$/.test(flags.ms))) throw Error('shift requires integer --ms.');
27
+ const { stat } = await import('node:fs/promises');
28
+ if ((await stat(path)).size > LIMITS.maxBytes) throw Error('Input exceeds 2 MiB.');
29
+ const result = parseSubtitles(await readFile(path, 'utf8'));
30
+ const report = inspectCues(result.cues);
31
+ const issues = [...result.issues, ...report.issues];
32
+ if (command === 'inspect') {
33
+ console.log(JSON.stringify({ format: result.format, summary: { ...report.summary, errors: issues.filter(i => i.severity === 'error').length, warnings: issues.filter(i => i.severity === 'warning').length }, issues, metrics: report.metrics }, null, 2));
34
+ process.exitCode = issues.some(i => i.severity === 'error') ? 1 : 0;
35
+ } else if (issues.some(i => i.severity === 'error')) {
36
+ console.error(JSON.stringify({ error: 'Fix invalid cues before export.', issues }, null, 2));
37
+ process.exitCode = 1;
38
+ } else {
39
+ const warnings = issues.filter(i => i.severity === 'warning');
40
+ if (result.cues.some(c => c.text !== c.text.replace(/<[^>\n]*>/g, '') || c.settings)) warnings.push({ code: 'LOSSY_EXPORT', severity: 'warning', message: 'Plain-text export removes markup; SRT export drops WebVTT cue settings.' });
41
+ if (warnings.length) console.error(JSON.stringify({ warnings }));
42
+ let cues = result.cues;
43
+ if (command === 'shift') {
44
+ const shifted = shiftCues(cues, Number(flags.ms), { negative: flags.clip ? 'clip' : 'reject' });
45
+ cues = shifted.cues;
46
+ if (shifted.droppedIndices.length || shifted.clippedIndices.length) console.error(JSON.stringify({ droppedIndices: shifted.droppedIndices, clippedIndices: shifted.clippedIndices }));
47
+ }
48
+ process.stdout.write(serializeSubtitles(cues, { format: flags.to ?? result.format }));
49
+ }
50
+ } catch (error) {
51
+ console.error(error.message);
52
+ process.exitCode = error instanceof RangeError ? 1 : 2;
53
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export interface Cue { id?: string; startMs: number; endMs: number; text: string; settings?: string }
2
+ export interface Issue { code: string; severity: 'error' | 'warning'; message: string; cueIndex?: number; relatedCueIndex?: number; blockIndex?: number }
3
+ export interface ParseResult { format: 'srt' | 'vtt'; cues: Cue[]; issues: Issue[] }
4
+ export interface Inspection { issues: Issue[]; metrics: { cueIndex: number; durationMs: number; characters: number; lines: number; cps: number | null }[]; summary: { cues: number; endMs: number; errors: number; warnings: number } }
5
+ export const VERSION: string;
6
+ export const LIMITS: Readonly<{ maxBytes: number; maxCues: number }>;
7
+ export function parseTimestamp(value: string, format?: 'srt' | 'vtt'): number;
8
+ export function formatTimestamp(ms: number, format?: 'srt' | 'vtt'): string;
9
+ /** Plain caption text extraction, not HTML sanitization. */
10
+ export function plainText(text: string): string;
11
+ export function parseSubtitles(input: string, options?: { format?: 'auto' | 'srt' | 'vtt'; maxBytes?: number; maxCues?: number }): ParseResult;
12
+ export function inspectCues(cues: Cue[], options?: { maxLineLength?: number; maxLines?: number; maxCps?: number }): Inspection;
13
+ export function shiftCues(cues: Cue[], offsetMs: number, options?: { negative?: 'reject' | 'clip' }): { cues: Cue[]; droppedIndices: number[]; clippedIndices: number[] };
14
+ export function serializeSubtitles(cues: Cue[], options?: { format?: 'srt' | 'vtt'; textMode?: 'plain' | 'preserve'; keepSettings?: boolean }): string;
package/src/index.js ADDED
@@ -0,0 +1,166 @@
1
+ /** Plain-text SRT and WebVTT utilities. All timestamps are integer milliseconds. */
2
+ export const VERSION = '0.1.0';
3
+ export const LIMITS = Object.freeze({ maxBytes: 2 * 1024 * 1024, maxCues: 10000 });
4
+
5
+ const issue = (code, severity, message, extra = {}) => ({ code, severity, message, ...extra });
6
+ const encoder = new TextEncoder();
7
+
8
+ export function parseTimestamp(value, format = 'vtt') {
9
+ if (typeof value !== 'string' || !['srt', 'vtt'].includes(format)) throw new TypeError('Expected a timestamp and srt or vtt format.');
10
+ const pattern = format === 'srt' ? /^(\d{2,}):(\d{2}):(\d{2}),(\d{3})$/ : /^(?:(\d{2,}):)?(\d{2}):(\d{2})\.(\d{3})$/;
11
+ const m = value.match(pattern);
12
+ if (!m) throw new RangeError(`Invalid ${format.toUpperCase()} timestamp: ${value}`);
13
+ const hours = Number(m[1] || 0), minutes = Number(m[2]), seconds = Number(m[3]);
14
+ const ms = ((hours * 60 + minutes) * 60 + seconds) * 1000 + Number(m[4]);
15
+ if (minutes > 59 || seconds > 59 || !Number.isSafeInteger(ms)) throw new RangeError(`Out-of-range timestamp: ${value}`);
16
+ return ms;
17
+ }
18
+
19
+ export function formatTimestamp(ms, format = 'srt') {
20
+ if (!Number.isSafeInteger(ms) || ms < 0) throw new RangeError('Time must be a non-negative safe integer in milliseconds.');
21
+ if (!['srt', 'vtt'].includes(format)) throw new TypeError('Format must be srt or vtt.');
22
+ const pad = (n, size = 2) => String(n).padStart(size, '0');
23
+ const h = Math.floor(ms / 3600000), m = Math.floor(ms / 60000) % 60, s = Math.floor(ms / 1000) % 60;
24
+ return `${pad(h)}:${pad(m)}:${pad(s)}${format === 'srt' ? ',' : '.'}${pad(ms % 1000, 3)}`;
25
+ }
26
+
27
+ /** Remove angle-bracket markup and common caption entities; not an HTML sanitizer. */
28
+ export function plainText(text) {
29
+ const entities = { amp: '&', lt: '<', gt: '>', nbsp: ' ', quot: '"', apos: "'" };
30
+ return String(text).replace(/<[^>\n]*>/g, '').replace(/\{\\[^}\n]*\}/g, '').replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|nbsp|quot|apos);/gi, (whole, entity) => {
31
+ if (entity[0] !== '#') return entities[entity.toLowerCase()] ?? whole;
32
+ const n = entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
33
+ return Number.isInteger(n) && n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : whole;
34
+ });
35
+ }
36
+
37
+ export function parseSubtitles(input, options = {}) {
38
+ if (typeof input !== 'string') throw new TypeError('Subtitle input must be a string.');
39
+ const maxBytes = options.maxBytes ?? LIMITS.maxBytes, maxCues = options.maxCues ?? LIMITS.maxCues;
40
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > LIMITS.maxBytes || !Number.isSafeInteger(maxCues) || maxCues < 1 || maxCues > LIMITS.maxCues) throw new RangeError('Limits must be positive integers no greater than 2 MiB / 10,000 cues.');
41
+ const formatOption = options.format ?? 'auto';
42
+ if (!['auto', 'srt', 'vtt'].includes(formatOption)) throw new TypeError('Format must be auto, srt or vtt.');
43
+ if (input.length > maxBytes || encoder.encode(input).length > maxBytes) return { format: formatOption === 'vtt' ? 'vtt' : 'srt', cues: [], issues: [issue('INPUT_LIMIT', 'error', `Input exceeds ${maxBytes} UTF-8 bytes.`)] };
44
+ const text = input.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
45
+ const format = formatOption === 'auto' ? (/^WEBVTT(?:[ \t].*)?(?:\n|$)/.test(text) ? 'vtt' : 'srt') : formatOption;
46
+ const issues = [], cues = [];
47
+ if (!text.trim()) return { format, cues, issues: [issue('EMPTY_INPUT', 'error', 'Paste or load a subtitle file.')] };
48
+ const blocks = text.split(/\n[ \t]*\n/);
49
+ if (format === 'vtt') {
50
+ const header = blocks.shift().split('\n');
51
+ if (!/^WEBVTT(?:[ \t].*)?$/.test(header[0]) || header[0].includes('-->')) return { format, cues, issues: [issue('VTT_HEADER', 'error', 'WebVTT needs a WEBVTT header followed by a blank line.')] };
52
+ if (header.slice(1).some(line => line.includes('-->'))) return { format, cues, issues: [issue('VTT_HEADER', 'error', 'Separate the WEBVTT header and first cue with a blank line.')] };
53
+ if (header.length > 1 || header[0] !== 'WEBVTT') issues.push(issue('HEADER_METADATA_OMITTED', 'warning', 'WebVTT header metadata is not retained on export.'));
54
+ }
55
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
56
+ const lines = blocks[blockIndex].replace(/^\n+|\n+$/g, '').split('\n');
57
+ if (lines.every(line => !line.trim())) continue;
58
+ if (format === 'vtt' && /^(NOTE(?:[ \t]|$)|STYLE$|REGION$)/.test(lines[0])) {
59
+ issues.push(issue('VTT_BLOCK_OMITTED', 'warning', 'NOTE, STYLE and REGION blocks are not retained on export.', { blockIndex }));
60
+ continue;
61
+ }
62
+ let timingIndex = lines[0].includes('-->') ? 0 : 1;
63
+ if (timingIndex === 1 && format === 'srt' && !/^\d+$/.test(lines[0].trim())) {
64
+ issues.push(issue('SRT_ID', 'error', 'An SRT cue identifier must be numeric.', { blockIndex }));
65
+ continue;
66
+ }
67
+ const timing = lines[timingIndex]?.match(/^(\S+)[ \t]+-->[ \t]+(\S+)(?:[ \t]+(.*))?$/);
68
+ if (!timing) {
69
+ issues.push(issue('CUE_TIMING', 'error', 'Expected a start --> end timestamp line.', { blockIndex }));
70
+ continue;
71
+ }
72
+ if (format === 'srt' && timing[3]) {
73
+ issues.push(issue('SRT_SETTINGS', 'error', 'This SRT subset does not support timing-line extensions.', { blockIndex }));
74
+ continue;
75
+ }
76
+ try {
77
+ const cue = { startMs: parseTimestamp(timing[1], format), endMs: parseTimestamp(timing[2], format), text: lines.slice(timingIndex + 1).join('\n') };
78
+ if (timingIndex === 1) cue.id = lines[0];
79
+ if (timing[3]) cue.settings = timing[3];
80
+ cues.push(cue);
81
+ if (cues.length > maxCues) return { format, cues: [], issues: [issue('CUE_LIMIT', 'error', `Input exceeds ${maxCues} cues.`)] };
82
+ } catch (error) {
83
+ issues.push(issue('TIMESTAMP', 'error', error.message, { blockIndex }));
84
+ }
85
+ }
86
+ if (!cues.length && !issues.some(i => i.severity === 'error')) issues.push(issue('NO_CUES', 'error', 'No subtitle cues found.'));
87
+ return { format, cues, issues };
88
+ }
89
+
90
+ function checkShape(cue) {
91
+ if (!cue || !Number.isSafeInteger(cue.startMs) || !Number.isSafeInteger(cue.endMs) || cue.startMs < 0 || cue.endMs < 0 || typeof cue.text !== 'string') throw new TypeError('Each cue needs non-negative safe-integer startMs/endMs and string text.');
92
+ }
93
+
94
+ export function inspectCues(cues, options = {}) {
95
+ if (!Array.isArray(cues) || cues.length > LIMITS.maxCues) throw new RangeError(`Expected up to ${LIMITS.maxCues} cues.`);
96
+ const maxLineLength = options.maxLineLength ?? 42, maxLines = options.maxLines ?? 2, maxCps = options.maxCps ?? 20;
97
+ for (const n of [maxLineLength, maxLines, maxCps]) if (!Number.isFinite(n) || n <= 0) throw new RangeError('Reading thresholds must be positive finite numbers.');
98
+ const issues = [], metrics = [];
99
+ cues.forEach((cue, cueIndex) => {
100
+ checkShape(cue);
101
+ const text = plainText(cue.text), lines = text.split('\n');
102
+ const durationMs = cue.endMs - cue.startMs, characters = Array.from(text.replace(/\n/g, '')).length;
103
+ const cps = durationMs > 0 ? characters / (durationMs / 1000) : null;
104
+ metrics.push({ cueIndex, durationMs, characters, lines: lines.length, cps });
105
+ const add = (code, severity, message) => issues.push(issue(code, severity, message, { cueIndex }));
106
+ if (!text.trim()) add('EMPTY_TEXT', 'error', 'Cue text is empty.');
107
+ if (durationMs < 0) add('REVERSED_TIME', 'error', 'Cue ends before it starts.');
108
+ if (durationMs === 0) add('ZERO_DURATION', 'error', 'Cue has zero duration.');
109
+ if (cueIndex > 0 && cue.startMs < cues[cueIndex - 1].startMs) add('OUT_OF_ORDER', 'warning', 'Cue start precedes the previous cue in file order.');
110
+ if (lines.some(line => Array.from(line).length > maxLineLength)) add('LONG_LINE', 'warning', `A line exceeds the ${maxLineLength}-character heuristic.`);
111
+ if (lines.length > maxLines) add('TOO_MANY_LINES', 'warning', `Cue exceeds the ${maxLines}-line heuristic.`);
112
+ if (cps !== null && cps > maxCps) add('FAST_READING', 'warning', `Reading speed is ${cps.toFixed(1)} characters/s; threshold is ${maxCps}.`);
113
+ });
114
+ let furthest = null;
115
+ for (const entry of cues.map((cue, cueIndex) => ({ cue, cueIndex })).filter(e => e.cue.endMs > e.cue.startMs).sort((a, b) => a.cue.startMs - b.cue.startMs || a.cueIndex - b.cueIndex)) {
116
+ if (furthest && entry.cue.startMs < furthest.cue.endMs) issues.push(issue('OVERLAP', 'warning', `Overlaps cue ${furthest.cueIndex + 1}. Overlap can be intentional.`, { cueIndex: entry.cueIndex, relatedCueIndex: furthest.cueIndex }));
117
+ if (!furthest || entry.cue.endMs > furthest.cue.endMs) furthest = entry;
118
+ }
119
+ return {
120
+ issues, metrics,
121
+ summary: { cues: cues.length, endMs: cues.reduce((n, c) => Math.max(n, c.endMs), 0), errors: issues.filter(i => i.severity === 'error').length, warnings: issues.filter(i => i.severity === 'warning').length }
122
+ };
123
+ }
124
+
125
+ export function shiftCues(cues, offsetMs, options = {}) {
126
+ if (!Array.isArray(cues) || cues.length > LIMITS.maxCues) throw new RangeError(`Expected up to ${LIMITS.maxCues} cues.`);
127
+ if (!Number.isSafeInteger(offsetMs)) throw new RangeError('Offset must be a safe integer in milliseconds.');
128
+ const negative = options.negative ?? 'reject';
129
+ if (!['reject', 'clip'].includes(negative)) throw new TypeError('Negative policy must be reject or clip.');
130
+ const output = [], droppedIndices = [], clippedIndices = [];
131
+ cues.forEach((cue, i) => {
132
+ checkShape(cue);
133
+ if (cue.endMs <= cue.startMs) throw new RangeError(`Cue ${i + 1} needs positive duration before shifting.`);
134
+ const start = cue.startMs + offsetMs, end = cue.endMs + offsetMs;
135
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) throw new RangeError('Shift exceeds safe integer limits.');
136
+ if (start < 0) {
137
+ if (negative === 'reject') throw new RangeError(`Cue ${i + 1} would start before zero. Choose clipping explicitly.`);
138
+ if (end <= 0) { droppedIndices.push(i); return; }
139
+ clippedIndices.push(i);
140
+ }
141
+ output.push({ ...cue, startMs: Math.max(0, start), endMs: end });
142
+ });
143
+ return { cues: output, droppedIndices, clippedIndices };
144
+ }
145
+
146
+ export function serializeSubtitles(cues, options = {}) {
147
+ const format = options.format ?? 'srt', textMode = options.textMode ?? 'plain';
148
+ if (!['srt', 'vtt'].includes(format) || !['plain', 'preserve'].includes(textMode)) throw new TypeError('Expected srt/vtt format and plain/preserve textMode.');
149
+ if (!Array.isArray(cues) || cues.length > LIMITS.maxCues) throw new RangeError(`Expected up to ${LIMITS.maxCues} cues.`);
150
+ const blocks = cues.map((cue, i) => {
151
+ checkShape(cue);
152
+ if (cue.endMs <= cue.startMs) throw new RangeError(`Cue ${i + 1} needs positive duration.`);
153
+ if (format === 'vtt' && i && cue.startMs < cues[i - 1].startMs) throw new RangeError('Sort cues by start time before WebVTT export.');
154
+ const text = textMode === 'plain' ? plainText(cue.text) : cue.text;
155
+ if (!text.trim() || /\r|\n[ \t]*\n|-->/.test(text)) throw new RangeError(`Cue ${i + 1} contains empty text or unsupported cue separators.`);
156
+ if (cue.id !== undefined && (typeof cue.id !== 'string' || /[\r\n]|-->/.test(cue.id))) throw new RangeError('Cue identifiers must stay on one line.');
157
+ if (format === 'vtt' && cue.id && /^(NOTE(?:[ \t]|$)|STYLE$|REGION$)/.test(cue.id)) throw new RangeError('Reserved WebVTT block names cannot be cue identifiers.');
158
+ if (cue.settings !== undefined && (typeof cue.settings !== 'string' || /[\r\n]|-->/.test(cue.settings))) throw new RangeError('Cue settings must stay on one line.');
159
+ const settings = format === 'vtt' && options.keepSettings !== false && cue.settings ? ` ${cue.settings}` : '';
160
+ const id = format === 'srt' ? `${i + 1}\n` : cue.id ? `${cue.id}\n` : '';
161
+ return `${id}${formatTimestamp(cue.startMs, format)} --> ${formatTimestamp(cue.endMs, format)}${settings}\n${text}`;
162
+ });
163
+ const output = `${format === 'vtt' ? 'WEBVTT\n\n' : ''}${blocks.join('\n\n')}${blocks.length ? '\n' : ''}`;
164
+ if (encoder.encode(output).length > LIMITS.maxBytes) throw new RangeError('Serialized output exceeds 2 MiB.');
165
+ return output;
166
+ }