srtsync 1.0.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/LICENSE +21 -0
- package/README.md +130 -0
- package/dist/cli.mjs +142 -0
- package/dist/index.cjs +138 -0
- package/dist/index.d.cts +58 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +104 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rodrigo Polo
|
|
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
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# srtsync
|
|
2
|
+
|
|
3
|
+
Resync SRT subtitle files — shift timestamps by a fixed offset or linearly rescale them to match a different video duration.
|
|
4
|
+
|
|
5
|
+
Works as a **CLI tool** and as a **Node.js / browser library**. Zero runtime dependencies.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install srtsync
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## CLI
|
|
14
|
+
|
|
15
|
+
### Shift all timestamps by a fixed offset
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
srtsync shift <offset> <file.srt> [-o output.srt]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The offset format is `[+|-]HH:MM:SS.mmm`:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Shift subtitles forward by 1.5 seconds
|
|
25
|
+
srtsync shift +00:00:01.500 movie.srt > movie-fixed.srt
|
|
26
|
+
|
|
27
|
+
# Shift subtitles back by 3 seconds, write to file
|
|
28
|
+
srtsync shift -00:00:03.000 movie.srt -o movie-fixed.srt
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Linearly rescale timestamps
|
|
32
|
+
|
|
33
|
+
Use this when the video has been rate-stretched (e.g. PAL speed-up from 24fps to 25fps) and all timestamps need proportional adjustment.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
srtsync linear <newFirst> <newLast> <file.srt> [-o output.srt]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
You provide the correct start time for the **first** and **last** subtitle. Every timestamp in between is linearly interpolated:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# The first subtitle should start at 00:00:21.278
|
|
43
|
+
# and the last subtitle should start at 01:18:48.956
|
|
44
|
+
srtsync linear 00:00:21.278 01:18:48.956 movie.srt > movie-fixed.srt
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Output goes to **stdout** by default (pipe-friendly), or use `-o` to write directly to a file.
|
|
48
|
+
|
|
49
|
+
## Library
|
|
50
|
+
|
|
51
|
+
### `timeShift(srtContent, offset)`
|
|
52
|
+
|
|
53
|
+
Parse an SRT string, shift all timestamps, and return the resynced SRT string.
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { timeShift } from "srtsync";
|
|
57
|
+
import { readFileSync } from "fs";
|
|
58
|
+
|
|
59
|
+
const srt = readFileSync("movie.srt", "utf-8");
|
|
60
|
+
const shifted = timeShift(srt, "+00:00:02.000");
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `linearCorrection(srtContent, newFirst, newLast)`
|
|
64
|
+
|
|
65
|
+
Parse an SRT string, linearly rescale all timestamps between two reference points, and return the resynced SRT string.
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { linearCorrection } from "srtsync";
|
|
69
|
+
import { readFileSync } from "fs";
|
|
70
|
+
|
|
71
|
+
const srt = readFileSync("movie.srt", "utf-8");
|
|
72
|
+
const corrected = linearCorrection(srt, "00:00:21.278", "01:18:48.956");
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Lower-level API
|
|
76
|
+
|
|
77
|
+
For more control, you can work with parsed subtitle objects directly:
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
import {
|
|
81
|
+
parseSrt,
|
|
82
|
+
formatSrt,
|
|
83
|
+
applyShift,
|
|
84
|
+
applyLinearCorrection,
|
|
85
|
+
parseTimestamp,
|
|
86
|
+
formatTimestamp,
|
|
87
|
+
} from "srtsync";
|
|
88
|
+
|
|
89
|
+
const subs = parseSrt(srtContent);
|
|
90
|
+
// => [{ index: 1, startMs: 20400, endMs: 21680, text: "Hello" }, ...]
|
|
91
|
+
|
|
92
|
+
// Shift by 5 seconds
|
|
93
|
+
const shifted = applyShift(subs, 5000);
|
|
94
|
+
|
|
95
|
+
// Or linearly rescale
|
|
96
|
+
const corrected = applyLinearCorrection(subs, 21278, 4728956);
|
|
97
|
+
|
|
98
|
+
// Serialize back to SRT
|
|
99
|
+
const output = formatSrt(corrected);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The `Subtitle` type is also exported:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import type { Subtitle } from "srtsync";
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## How linear correction works
|
|
109
|
+
|
|
110
|
+
Given the start time of the first and last subtitles as reference points, every timestamp is rescaled with a linear mapping:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
scale = (newLast - newFirst) / (origLast - origFirst)
|
|
114
|
+
new_t = newFirst + (t - origFirst) * scale
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This corrects uniform drift caused by frame-rate conversion, player speed changes, or different video cuts.
|
|
118
|
+
|
|
119
|
+
## Timestamp format
|
|
120
|
+
|
|
121
|
+
All timestamp arguments accept both SRT-standard comma separators and dot separators:
|
|
122
|
+
|
|
123
|
+
- `00:01:30,500` (SRT standard)
|
|
124
|
+
- `00:01:30.500` (also accepted)
|
|
125
|
+
|
|
126
|
+
Offsets accept an optional `+`/`-` sign prefix. Unsigned offsets are treated as positive.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
5
|
+
|
|
6
|
+
// src/time.ts
|
|
7
|
+
function parseTimestamp(ts) {
|
|
8
|
+
const m = ts.trim().match(/^(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$/);
|
|
9
|
+
if (!m) throw new Error(`Invalid timestamp: "${ts}"`);
|
|
10
|
+
const [, h, min, sec, ms] = m;
|
|
11
|
+
return Number(h) * 36e5 + Number(min) * 6e4 + Number(sec) * 1e3 + Number(ms);
|
|
12
|
+
}
|
|
13
|
+
function formatTimestamp(ms) {
|
|
14
|
+
if (ms < 0) ms = 0;
|
|
15
|
+
const h = Math.floor(ms / 36e5);
|
|
16
|
+
ms %= 36e5;
|
|
17
|
+
const min = Math.floor(ms / 6e4);
|
|
18
|
+
ms %= 6e4;
|
|
19
|
+
const sec = Math.floor(ms / 1e3);
|
|
20
|
+
const millis = ms % 1e3;
|
|
21
|
+
return String(h).padStart(2, "0") + ":" + String(min).padStart(2, "0") + ":" + String(sec).padStart(2, "0") + "," + String(millis).padStart(3, "0");
|
|
22
|
+
}
|
|
23
|
+
function parseOffset(offset) {
|
|
24
|
+
const m = offset.trim().match(/^([+-]?)(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$/);
|
|
25
|
+
if (!m) throw new Error(`Invalid offset: "${offset}"`);
|
|
26
|
+
const [, sign, h, min, sec, ms] = m;
|
|
27
|
+
const value = Number(h) * 36e5 + Number(min) * 6e4 + Number(sec) * 1e3 + Number(ms);
|
|
28
|
+
return sign === "-" ? -value : value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/parser.ts
|
|
32
|
+
function parseSrt(content) {
|
|
33
|
+
const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
34
|
+
const blocks = normalized.trim().split(/\n\n+/);
|
|
35
|
+
const subtitles = [];
|
|
36
|
+
for (const block of blocks) {
|
|
37
|
+
const lines = block.trim().split("\n");
|
|
38
|
+
if (lines.length < 3) continue;
|
|
39
|
+
const index = parseInt(lines[0], 10);
|
|
40
|
+
if (isNaN(index)) continue;
|
|
41
|
+
const timeLine = lines[1];
|
|
42
|
+
const timeMatch = timeLine.match(
|
|
43
|
+
/(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})/
|
|
44
|
+
);
|
|
45
|
+
if (!timeMatch) continue;
|
|
46
|
+
const startMs = parseTimestamp(timeMatch[1]);
|
|
47
|
+
const endMs = parseTimestamp(timeMatch[2]);
|
|
48
|
+
const text = lines.slice(2).join("\n");
|
|
49
|
+
subtitles.push({ index, startMs, endMs, text });
|
|
50
|
+
}
|
|
51
|
+
return subtitles;
|
|
52
|
+
}
|
|
53
|
+
function formatSrt(subtitles) {
|
|
54
|
+
return subtitles.map((sub, i) => {
|
|
55
|
+
const idx = i + 1;
|
|
56
|
+
const start = formatTimestamp(sub.startMs);
|
|
57
|
+
const end = formatTimestamp(sub.endMs);
|
|
58
|
+
return `${idx}
|
|
59
|
+
${start} --> ${end}
|
|
60
|
+
${sub.text}`;
|
|
61
|
+
}).join("\n\n") + "\n";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/shift.ts
|
|
65
|
+
function applyShift(subtitles, offsetMs) {
|
|
66
|
+
return subtitles.map((sub) => ({
|
|
67
|
+
...sub,
|
|
68
|
+
startMs: Math.max(0, sub.startMs + offsetMs),
|
|
69
|
+
endMs: Math.max(0, sub.endMs + offsetMs)
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/linear.ts
|
|
74
|
+
function applyLinearCorrection(subtitles, newFirstMs, newLastMs) {
|
|
75
|
+
if (subtitles.length < 2) {
|
|
76
|
+
throw new Error("Need at least 2 subtitles for linear correction");
|
|
77
|
+
}
|
|
78
|
+
const origFirst = subtitles[0].startMs;
|
|
79
|
+
const origLast = subtitles[subtitles.length - 1].startMs;
|
|
80
|
+
const scale = (newLastMs - newFirstMs) / (origLast - origFirst);
|
|
81
|
+
return subtitles.map((sub) => ({
|
|
82
|
+
...sub,
|
|
83
|
+
startMs: Math.max(0, Math.round(newFirstMs + (sub.startMs - origFirst) * scale)),
|
|
84
|
+
endMs: Math.max(0, Math.round(newFirstMs + (sub.endMs - origFirst) * scale))
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/index.ts
|
|
89
|
+
function timeShift(srtContent, offset) {
|
|
90
|
+
const subs = parseSrt(srtContent);
|
|
91
|
+
const offsetMs = parseOffset(offset);
|
|
92
|
+
return formatSrt(applyShift(subs, offsetMs));
|
|
93
|
+
}
|
|
94
|
+
function linearCorrection(srtContent, newFirst, newLast) {
|
|
95
|
+
const subs = parseSrt(srtContent);
|
|
96
|
+
const newFirstMs = parseTimestamp(newFirst);
|
|
97
|
+
const newLastMs = parseTimestamp(newLast);
|
|
98
|
+
return formatSrt(applyLinearCorrection(subs, newFirstMs, newLastMs));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/cli.ts
|
|
102
|
+
function usage() {
|
|
103
|
+
console.error(
|
|
104
|
+
`Usage:
|
|
105
|
+
srtsync shift <offset> <file.srt> [-o output.srt]
|
|
106
|
+
srtsync linear <newFirst> <newLast> <file.srt> [-o output.srt]
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
srtsync shift +00:00:01.000 input.srt > output.srt
|
|
110
|
+
srtsync linear 00:00:21.278 01:18:48.956 input.srt -o output.srt`
|
|
111
|
+
);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
function main() {
|
|
115
|
+
const args = process.argv.slice(2);
|
|
116
|
+
if (args.length < 1) usage();
|
|
117
|
+
const command = args[0];
|
|
118
|
+
let result;
|
|
119
|
+
if (command === "shift") {
|
|
120
|
+
if (args.length < 3) usage();
|
|
121
|
+
const offset = args[1];
|
|
122
|
+
const file = args[2];
|
|
123
|
+
const content = readFileSync(file, "utf-8");
|
|
124
|
+
result = timeShift(content, offset);
|
|
125
|
+
} else if (command === "linear") {
|
|
126
|
+
if (args.length < 4) usage();
|
|
127
|
+
const newFirst = args[1];
|
|
128
|
+
const newLast = args[2];
|
|
129
|
+
const file = args[3];
|
|
130
|
+
const content = readFileSync(file, "utf-8");
|
|
131
|
+
result = linearCorrection(content, newFirst, newLast);
|
|
132
|
+
} else {
|
|
133
|
+
usage();
|
|
134
|
+
}
|
|
135
|
+
const oIdx = args.indexOf("-o");
|
|
136
|
+
if (oIdx !== -1 && args[oIdx + 1]) {
|
|
137
|
+
writeFileSync(args[oIdx + 1], result, "utf-8");
|
|
138
|
+
} else {
|
|
139
|
+
process.stdout.write(result);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
main();
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
applyLinearCorrection: () => applyLinearCorrection,
|
|
24
|
+
applyShift: () => applyShift,
|
|
25
|
+
formatSrt: () => formatSrt,
|
|
26
|
+
formatTimestamp: () => formatTimestamp,
|
|
27
|
+
linearCorrection: () => linearCorrection,
|
|
28
|
+
parseSrt: () => parseSrt,
|
|
29
|
+
parseTimestamp: () => parseTimestamp,
|
|
30
|
+
timeShift: () => timeShift
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
|
|
34
|
+
// src/time.ts
|
|
35
|
+
function parseTimestamp(ts) {
|
|
36
|
+
const m = ts.trim().match(/^(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$/);
|
|
37
|
+
if (!m) throw new Error(`Invalid timestamp: "${ts}"`);
|
|
38
|
+
const [, h, min, sec, ms] = m;
|
|
39
|
+
return Number(h) * 36e5 + Number(min) * 6e4 + Number(sec) * 1e3 + Number(ms);
|
|
40
|
+
}
|
|
41
|
+
function formatTimestamp(ms) {
|
|
42
|
+
if (ms < 0) ms = 0;
|
|
43
|
+
const h = Math.floor(ms / 36e5);
|
|
44
|
+
ms %= 36e5;
|
|
45
|
+
const min = Math.floor(ms / 6e4);
|
|
46
|
+
ms %= 6e4;
|
|
47
|
+
const sec = Math.floor(ms / 1e3);
|
|
48
|
+
const millis = ms % 1e3;
|
|
49
|
+
return String(h).padStart(2, "0") + ":" + String(min).padStart(2, "0") + ":" + String(sec).padStart(2, "0") + "," + String(millis).padStart(3, "0");
|
|
50
|
+
}
|
|
51
|
+
function parseOffset(offset) {
|
|
52
|
+
const m = offset.trim().match(/^([+-]?)(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$/);
|
|
53
|
+
if (!m) throw new Error(`Invalid offset: "${offset}"`);
|
|
54
|
+
const [, sign, h, min, sec, ms] = m;
|
|
55
|
+
const value = Number(h) * 36e5 + Number(min) * 6e4 + Number(sec) * 1e3 + Number(ms);
|
|
56
|
+
return sign === "-" ? -value : value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/parser.ts
|
|
60
|
+
function parseSrt(content) {
|
|
61
|
+
const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
62
|
+
const blocks = normalized.trim().split(/\n\n+/);
|
|
63
|
+
const subtitles = [];
|
|
64
|
+
for (const block of blocks) {
|
|
65
|
+
const lines = block.trim().split("\n");
|
|
66
|
+
if (lines.length < 3) continue;
|
|
67
|
+
const index = parseInt(lines[0], 10);
|
|
68
|
+
if (isNaN(index)) continue;
|
|
69
|
+
const timeLine = lines[1];
|
|
70
|
+
const timeMatch = timeLine.match(
|
|
71
|
+
/(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})/
|
|
72
|
+
);
|
|
73
|
+
if (!timeMatch) continue;
|
|
74
|
+
const startMs = parseTimestamp(timeMatch[1]);
|
|
75
|
+
const endMs = parseTimestamp(timeMatch[2]);
|
|
76
|
+
const text = lines.slice(2).join("\n");
|
|
77
|
+
subtitles.push({ index, startMs, endMs, text });
|
|
78
|
+
}
|
|
79
|
+
return subtitles;
|
|
80
|
+
}
|
|
81
|
+
function formatSrt(subtitles) {
|
|
82
|
+
return subtitles.map((sub, i) => {
|
|
83
|
+
const idx = i + 1;
|
|
84
|
+
const start = formatTimestamp(sub.startMs);
|
|
85
|
+
const end = formatTimestamp(sub.endMs);
|
|
86
|
+
return `${idx}
|
|
87
|
+
${start} --> ${end}
|
|
88
|
+
${sub.text}`;
|
|
89
|
+
}).join("\n\n") + "\n";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/shift.ts
|
|
93
|
+
function applyShift(subtitles, offsetMs) {
|
|
94
|
+
return subtitles.map((sub) => ({
|
|
95
|
+
...sub,
|
|
96
|
+
startMs: Math.max(0, sub.startMs + offsetMs),
|
|
97
|
+
endMs: Math.max(0, sub.endMs + offsetMs)
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/linear.ts
|
|
102
|
+
function applyLinearCorrection(subtitles, newFirstMs, newLastMs) {
|
|
103
|
+
if (subtitles.length < 2) {
|
|
104
|
+
throw new Error("Need at least 2 subtitles for linear correction");
|
|
105
|
+
}
|
|
106
|
+
const origFirst = subtitles[0].startMs;
|
|
107
|
+
const origLast = subtitles[subtitles.length - 1].startMs;
|
|
108
|
+
const scale = (newLastMs - newFirstMs) / (origLast - origFirst);
|
|
109
|
+
return subtitles.map((sub) => ({
|
|
110
|
+
...sub,
|
|
111
|
+
startMs: Math.max(0, Math.round(newFirstMs + (sub.startMs - origFirst) * scale)),
|
|
112
|
+
endMs: Math.max(0, Math.round(newFirstMs + (sub.endMs - origFirst) * scale))
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/index.ts
|
|
117
|
+
function timeShift(srtContent, offset) {
|
|
118
|
+
const subs = parseSrt(srtContent);
|
|
119
|
+
const offsetMs = parseOffset(offset);
|
|
120
|
+
return formatSrt(applyShift(subs, offsetMs));
|
|
121
|
+
}
|
|
122
|
+
function linearCorrection(srtContent, newFirst, newLast) {
|
|
123
|
+
const subs = parseSrt(srtContent);
|
|
124
|
+
const newFirstMs = parseTimestamp(newFirst);
|
|
125
|
+
const newLastMs = parseTimestamp(newLast);
|
|
126
|
+
return formatSrt(applyLinearCorrection(subs, newFirstMs, newLastMs));
|
|
127
|
+
}
|
|
128
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
129
|
+
0 && (module.exports = {
|
|
130
|
+
applyLinearCorrection,
|
|
131
|
+
applyShift,
|
|
132
|
+
formatSrt,
|
|
133
|
+
formatTimestamp,
|
|
134
|
+
linearCorrection,
|
|
135
|
+
parseSrt,
|
|
136
|
+
parseTimestamp,
|
|
137
|
+
timeShift
|
|
138
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
interface Subtitle {
|
|
2
|
+
index: number;
|
|
3
|
+
startMs: number;
|
|
4
|
+
endMs: number;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse an SRT file into an array of Subtitle objects.
|
|
9
|
+
*/
|
|
10
|
+
declare function parseSrt(content: string): Subtitle[];
|
|
11
|
+
/**
|
|
12
|
+
* Serialize an array of Subtitle objects back to SRT format.
|
|
13
|
+
* Re-indexes subtitles starting from 1.
|
|
14
|
+
*/
|
|
15
|
+
declare function formatSrt(subtitles: Subtitle[]): string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse an SRT timestamp (HH:MM:SS,mmm or HH:MM:SS.mmm) into milliseconds.
|
|
19
|
+
*/
|
|
20
|
+
declare function parseTimestamp(ts: string): number;
|
|
21
|
+
/**
|
|
22
|
+
* Format milliseconds into SRT timestamp format (HH:MM:SS,mmm).
|
|
23
|
+
* Clamps negative values to 0.
|
|
24
|
+
*/
|
|
25
|
+
declare function formatTimestamp(ms: number): string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Add a fixed offset (in milliseconds) to every subtitle timestamp.
|
|
29
|
+
* Clamps results to 0.
|
|
30
|
+
*/
|
|
31
|
+
declare function applyShift(subtitles: Subtitle[], offsetMs: number): Subtitle[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Linearly rescale all timestamps so that the first subtitle's start time
|
|
35
|
+
* maps to `newFirstMs` and the last subtitle's start time maps to `newLastMs`.
|
|
36
|
+
*/
|
|
37
|
+
declare function applyLinearCorrection(subtitles: Subtitle[], newFirstMs: number, newLastMs: number): Subtitle[];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse an SRT string, shift all timestamps by the given offset, and return
|
|
41
|
+
* the resynced SRT string.
|
|
42
|
+
*
|
|
43
|
+
* @param srtContent - Raw SRT file content
|
|
44
|
+
* @param offset - Signed offset like "+00:00:01.000" or "-00:00:02.500"
|
|
45
|
+
*/
|
|
46
|
+
declare function timeShift(srtContent: string, offset: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Parse an SRT string, linearly rescale timestamps so that the first subtitle
|
|
49
|
+
* starts at `newFirst` and the last subtitle starts at `newLast`, and return
|
|
50
|
+
* the resynced SRT string.
|
|
51
|
+
*
|
|
52
|
+
* @param srtContent - Raw SRT file content
|
|
53
|
+
* @param newFirst - New start time for the first subtitle (e.g. "00:00:21.278")
|
|
54
|
+
* @param newLast - New start time for the last subtitle (e.g. "01:18:48.956")
|
|
55
|
+
*/
|
|
56
|
+
declare function linearCorrection(srtContent: string, newFirst: string, newLast: string): string;
|
|
57
|
+
|
|
58
|
+
export { type Subtitle, applyLinearCorrection, applyShift, formatSrt, formatTimestamp, linearCorrection, parseSrt, parseTimestamp, timeShift };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
interface Subtitle {
|
|
2
|
+
index: number;
|
|
3
|
+
startMs: number;
|
|
4
|
+
endMs: number;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse an SRT file into an array of Subtitle objects.
|
|
9
|
+
*/
|
|
10
|
+
declare function parseSrt(content: string): Subtitle[];
|
|
11
|
+
/**
|
|
12
|
+
* Serialize an array of Subtitle objects back to SRT format.
|
|
13
|
+
* Re-indexes subtitles starting from 1.
|
|
14
|
+
*/
|
|
15
|
+
declare function formatSrt(subtitles: Subtitle[]): string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse an SRT timestamp (HH:MM:SS,mmm or HH:MM:SS.mmm) into milliseconds.
|
|
19
|
+
*/
|
|
20
|
+
declare function parseTimestamp(ts: string): number;
|
|
21
|
+
/**
|
|
22
|
+
* Format milliseconds into SRT timestamp format (HH:MM:SS,mmm).
|
|
23
|
+
* Clamps negative values to 0.
|
|
24
|
+
*/
|
|
25
|
+
declare function formatTimestamp(ms: number): string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Add a fixed offset (in milliseconds) to every subtitle timestamp.
|
|
29
|
+
* Clamps results to 0.
|
|
30
|
+
*/
|
|
31
|
+
declare function applyShift(subtitles: Subtitle[], offsetMs: number): Subtitle[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Linearly rescale all timestamps so that the first subtitle's start time
|
|
35
|
+
* maps to `newFirstMs` and the last subtitle's start time maps to `newLastMs`.
|
|
36
|
+
*/
|
|
37
|
+
declare function applyLinearCorrection(subtitles: Subtitle[], newFirstMs: number, newLastMs: number): Subtitle[];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse an SRT string, shift all timestamps by the given offset, and return
|
|
41
|
+
* the resynced SRT string.
|
|
42
|
+
*
|
|
43
|
+
* @param srtContent - Raw SRT file content
|
|
44
|
+
* @param offset - Signed offset like "+00:00:01.000" or "-00:00:02.500"
|
|
45
|
+
*/
|
|
46
|
+
declare function timeShift(srtContent: string, offset: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Parse an SRT string, linearly rescale timestamps so that the first subtitle
|
|
49
|
+
* starts at `newFirst` and the last subtitle starts at `newLast`, and return
|
|
50
|
+
* the resynced SRT string.
|
|
51
|
+
*
|
|
52
|
+
* @param srtContent - Raw SRT file content
|
|
53
|
+
* @param newFirst - New start time for the first subtitle (e.g. "00:00:21.278")
|
|
54
|
+
* @param newLast - New start time for the last subtitle (e.g. "01:18:48.956")
|
|
55
|
+
*/
|
|
56
|
+
declare function linearCorrection(srtContent: string, newFirst: string, newLast: string): string;
|
|
57
|
+
|
|
58
|
+
export { type Subtitle, applyLinearCorrection, applyShift, formatSrt, formatTimestamp, linearCorrection, parseSrt, parseTimestamp, timeShift };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// src/time.ts
|
|
2
|
+
function parseTimestamp(ts) {
|
|
3
|
+
const m = ts.trim().match(/^(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$/);
|
|
4
|
+
if (!m) throw new Error(`Invalid timestamp: "${ts}"`);
|
|
5
|
+
const [, h, min, sec, ms] = m;
|
|
6
|
+
return Number(h) * 36e5 + Number(min) * 6e4 + Number(sec) * 1e3 + Number(ms);
|
|
7
|
+
}
|
|
8
|
+
function formatTimestamp(ms) {
|
|
9
|
+
if (ms < 0) ms = 0;
|
|
10
|
+
const h = Math.floor(ms / 36e5);
|
|
11
|
+
ms %= 36e5;
|
|
12
|
+
const min = Math.floor(ms / 6e4);
|
|
13
|
+
ms %= 6e4;
|
|
14
|
+
const sec = Math.floor(ms / 1e3);
|
|
15
|
+
const millis = ms % 1e3;
|
|
16
|
+
return String(h).padStart(2, "0") + ":" + String(min).padStart(2, "0") + ":" + String(sec).padStart(2, "0") + "," + String(millis).padStart(3, "0");
|
|
17
|
+
}
|
|
18
|
+
function parseOffset(offset) {
|
|
19
|
+
const m = offset.trim().match(/^([+-]?)(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$/);
|
|
20
|
+
if (!m) throw new Error(`Invalid offset: "${offset}"`);
|
|
21
|
+
const [, sign, h, min, sec, ms] = m;
|
|
22
|
+
const value = Number(h) * 36e5 + Number(min) * 6e4 + Number(sec) * 1e3 + Number(ms);
|
|
23
|
+
return sign === "-" ? -value : value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/parser.ts
|
|
27
|
+
function parseSrt(content) {
|
|
28
|
+
const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
29
|
+
const blocks = normalized.trim().split(/\n\n+/);
|
|
30
|
+
const subtitles = [];
|
|
31
|
+
for (const block of blocks) {
|
|
32
|
+
const lines = block.trim().split("\n");
|
|
33
|
+
if (lines.length < 3) continue;
|
|
34
|
+
const index = parseInt(lines[0], 10);
|
|
35
|
+
if (isNaN(index)) continue;
|
|
36
|
+
const timeLine = lines[1];
|
|
37
|
+
const timeMatch = timeLine.match(
|
|
38
|
+
/(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})/
|
|
39
|
+
);
|
|
40
|
+
if (!timeMatch) continue;
|
|
41
|
+
const startMs = parseTimestamp(timeMatch[1]);
|
|
42
|
+
const endMs = parseTimestamp(timeMatch[2]);
|
|
43
|
+
const text = lines.slice(2).join("\n");
|
|
44
|
+
subtitles.push({ index, startMs, endMs, text });
|
|
45
|
+
}
|
|
46
|
+
return subtitles;
|
|
47
|
+
}
|
|
48
|
+
function formatSrt(subtitles) {
|
|
49
|
+
return subtitles.map((sub, i) => {
|
|
50
|
+
const idx = i + 1;
|
|
51
|
+
const start = formatTimestamp(sub.startMs);
|
|
52
|
+
const end = formatTimestamp(sub.endMs);
|
|
53
|
+
return `${idx}
|
|
54
|
+
${start} --> ${end}
|
|
55
|
+
${sub.text}`;
|
|
56
|
+
}).join("\n\n") + "\n";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/shift.ts
|
|
60
|
+
function applyShift(subtitles, offsetMs) {
|
|
61
|
+
return subtitles.map((sub) => ({
|
|
62
|
+
...sub,
|
|
63
|
+
startMs: Math.max(0, sub.startMs + offsetMs),
|
|
64
|
+
endMs: Math.max(0, sub.endMs + offsetMs)
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/linear.ts
|
|
69
|
+
function applyLinearCorrection(subtitles, newFirstMs, newLastMs) {
|
|
70
|
+
if (subtitles.length < 2) {
|
|
71
|
+
throw new Error("Need at least 2 subtitles for linear correction");
|
|
72
|
+
}
|
|
73
|
+
const origFirst = subtitles[0].startMs;
|
|
74
|
+
const origLast = subtitles[subtitles.length - 1].startMs;
|
|
75
|
+
const scale = (newLastMs - newFirstMs) / (origLast - origFirst);
|
|
76
|
+
return subtitles.map((sub) => ({
|
|
77
|
+
...sub,
|
|
78
|
+
startMs: Math.max(0, Math.round(newFirstMs + (sub.startMs - origFirst) * scale)),
|
|
79
|
+
endMs: Math.max(0, Math.round(newFirstMs + (sub.endMs - origFirst) * scale))
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/index.ts
|
|
84
|
+
function timeShift(srtContent, offset) {
|
|
85
|
+
const subs = parseSrt(srtContent);
|
|
86
|
+
const offsetMs = parseOffset(offset);
|
|
87
|
+
return formatSrt(applyShift(subs, offsetMs));
|
|
88
|
+
}
|
|
89
|
+
function linearCorrection(srtContent, newFirst, newLast) {
|
|
90
|
+
const subs = parseSrt(srtContent);
|
|
91
|
+
const newFirstMs = parseTimestamp(newFirst);
|
|
92
|
+
const newLastMs = parseTimestamp(newLast);
|
|
93
|
+
return formatSrt(applyLinearCorrection(subs, newFirstMs, newLastMs));
|
|
94
|
+
}
|
|
95
|
+
export {
|
|
96
|
+
applyLinearCorrection,
|
|
97
|
+
applyShift,
|
|
98
|
+
formatSrt,
|
|
99
|
+
formatTimestamp,
|
|
100
|
+
linearCorrection,
|
|
101
|
+
parseSrt,
|
|
102
|
+
parseTimestamp,
|
|
103
|
+
timeShift
|
|
104
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "srtsync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Resync SRT subtitle files — shift timestamps or linearly rescale them",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"srtsync": "./dist/cli.mjs"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup",
|
|
29
|
+
"test": "vitest run"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"tsup": "^8.4.0",
|
|
33
|
+
"typescript": "^5.7.0",
|
|
34
|
+
"vitest": "^3.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|