@invintusmedia/tomp4 1.0.2 → 1.0.4
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/README.md +35 -13
- package/dist/tomp4.js +142 -2
- package/package.json +6 -3
- package/src/hls.js +66 -6
- package/src/index.d.ts +36 -0
- package/src/index.js +22 -3
- package/src/ts-to-mp4.js +140 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
<div><b>toMp4</b></div>
|
|
3
3
|
<div>turn streams into files</div>
|
|
4
|
-
<div><code>npm install tomp4</code></div>
|
|
4
|
+
<div><code>npm install @invintusmedia/tomp4</code></div>
|
|
5
5
|
</div>
|
|
6
6
|
|
|
7
7
|
|
|
@@ -11,7 +11,7 @@ you've got an HLS stream, or some `.ts` segments, or fMP4 chunks.
|
|
|
11
11
|
you want an `.mp4` file.
|
|
12
12
|
|
|
13
13
|
```js
|
|
14
|
-
import toMp4 from 'tomp4'
|
|
14
|
+
import toMp4 from '@invintusmedia/tomp4'
|
|
15
15
|
|
|
16
16
|
const mp4 = await toMp4('https://example.com/stream.m3u8')
|
|
17
17
|
mp4.download('my-video.mp4')
|
|
@@ -39,19 +39,42 @@ console.log(hls.qualities) // ['1080p', '720p', '480p']
|
|
|
39
39
|
const mp4 = await toMp4(hls.select('720p'))
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
###
|
|
42
|
+
### clip to time range
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
// one-step: download HLS + clip (only fetches needed segments)
|
|
46
|
+
const mp4 = await toMp4('https://example.com/stream.m3u8', {
|
|
47
|
+
startTime: 0,
|
|
48
|
+
endTime: 30
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// clip existing data (snaps to keyframes)
|
|
52
|
+
const mp4 = await toMp4(data, {
|
|
53
|
+
startTime: 5,
|
|
54
|
+
endTime: 15
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### analyze without converting
|
|
43
59
|
|
|
44
60
|
```js
|
|
45
|
-
|
|
46
|
-
mp4.download('video.mp4')
|
|
61
|
+
const info = toMp4.analyze(tsData)
|
|
47
62
|
|
|
48
|
-
//
|
|
49
|
-
|
|
63
|
+
info.duration // 99.5 (seconds)
|
|
64
|
+
info.keyframes // [{index: 0, time: 0}, {index: 150, time: 5.0}, ...]
|
|
65
|
+
info.videoCodec // "H.264/AVC"
|
|
66
|
+
info.audioCodec // "AAC"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### use the result
|
|
50
70
|
|
|
51
|
-
|
|
52
|
-
mp4.
|
|
53
|
-
mp4.
|
|
54
|
-
mp4.
|
|
71
|
+
```js
|
|
72
|
+
mp4.download('video.mp4') // trigger download
|
|
73
|
+
video.src = mp4.toURL() // play in video element
|
|
74
|
+
mp4.data // Uint8Array
|
|
75
|
+
mp4.toBlob() // Blob
|
|
76
|
+
mp4.toArrayBuffer() // ArrayBuffer
|
|
77
|
+
mp4.revokeURL() // free memory
|
|
55
78
|
```
|
|
56
79
|
|
|
57
80
|
|
|
@@ -85,11 +108,10 @@ works in both. ~50kb minified.
|
|
|
85
108
|
|
|
86
109
|
```html
|
|
87
110
|
<script type="module">
|
|
88
|
-
import toMp4 from '
|
|
111
|
+
import toMp4 from '@invintusmedia/tomp4'
|
|
89
112
|
</script>
|
|
90
113
|
```
|
|
91
114
|
|
|
92
115
|
|
|
93
116
|
|
|
94
117
|
MIT
|
|
95
|
-
|
package/dist/tomp4.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* toMp4.js v1.0.
|
|
2
|
+
* toMp4.js v1.0.4
|
|
3
3
|
* Convert MPEG-TS and fMP4 to standard MP4
|
|
4
4
|
* https://github.com/TVWIT/toMp4.js
|
|
5
5
|
* MIT License
|
|
@@ -1085,15 +1085,132 @@
|
|
|
1085
1085
|
return STREAM_TYPES[streamType] || { name: `Unknown (0x${streamType?.toString(16)})`, supported: false };
|
|
1086
1086
|
}
|
|
1087
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* Check if a video access unit contains a keyframe (IDR NAL unit)
|
|
1090
|
+
*/
|
|
1091
|
+
function isKeyframe(accessUnit) {
|
|
1092
|
+
for (const nalUnit of accessUnit.nalUnits) {
|
|
1093
|
+
const nalType = nalUnit[0] & 0x1F;
|
|
1094
|
+
if (nalType === 5) return true; // IDR slice
|
|
1095
|
+
}
|
|
1096
|
+
return false;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Clip access units to a time range, snapping to keyframes
|
|
1101
|
+
* @param {Array} videoAUs - Video access units
|
|
1102
|
+
* @param {Array} audioAUs - Audio access units
|
|
1103
|
+
* @param {number} startTime - Start time in seconds
|
|
1104
|
+
* @param {number} endTime - End time in seconds
|
|
1105
|
+
* @returns {object} Clipped access units and info
|
|
1106
|
+
*/
|
|
1107
|
+
function clipAccessUnits(videoAUs, audioAUs, startTime, endTime) {
|
|
1108
|
+
const PTS_PER_SECOND = 90000;
|
|
1109
|
+
const startPts = startTime * PTS_PER_SECOND;
|
|
1110
|
+
const endPts = endTime * PTS_PER_SECOND;
|
|
1111
|
+
|
|
1112
|
+
// Find keyframe at or before startTime
|
|
1113
|
+
let startIdx = 0;
|
|
1114
|
+
for (let i = 0; i < videoAUs.length; i++) {
|
|
1115
|
+
if (videoAUs[i].pts > startPts) break;
|
|
1116
|
+
if (isKeyframe(videoAUs[i])) startIdx = i;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Find first frame after endTime
|
|
1120
|
+
let endIdx = videoAUs.length;
|
|
1121
|
+
for (let i = startIdx; i < videoAUs.length; i++) {
|
|
1122
|
+
if (videoAUs[i].pts >= endPts) {
|
|
1123
|
+
endIdx = i;
|
|
1124
|
+
break;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Clip video
|
|
1129
|
+
const clippedVideo = videoAUs.slice(startIdx, endIdx);
|
|
1130
|
+
|
|
1131
|
+
// Get actual PTS range from clipped video
|
|
1132
|
+
const actualStartPts = clippedVideo.length > 0 ? clippedVideo[0].pts : 0;
|
|
1133
|
+
const actualEndPts = clippedVideo.length > 0 ? clippedVideo[clippedVideo.length - 1].pts : 0;
|
|
1134
|
+
|
|
1135
|
+
// Clip audio to match video time range
|
|
1136
|
+
const clippedAudio = audioAUs.filter(au => au.pts >= actualStartPts && au.pts <= actualEndPts);
|
|
1137
|
+
|
|
1138
|
+
// Normalize timestamps so clip starts at 0
|
|
1139
|
+
const offset = actualStartPts;
|
|
1140
|
+
for (const au of clippedVideo) {
|
|
1141
|
+
au.pts -= offset;
|
|
1142
|
+
au.dts -= offset;
|
|
1143
|
+
}
|
|
1144
|
+
for (const au of clippedAudio) {
|
|
1145
|
+
au.pts -= offset;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return {
|
|
1149
|
+
video: clippedVideo,
|
|
1150
|
+
audio: clippedAudio,
|
|
1151
|
+
actualStartTime: actualStartPts / PTS_PER_SECOND,
|
|
1152
|
+
actualEndTime: actualEndPts / PTS_PER_SECOND,
|
|
1153
|
+
offset
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1088
1157
|
/**
|
|
1089
1158
|
* Convert MPEG-TS data to MP4
|
|
1090
1159
|
*
|
|
1091
1160
|
* @param {Uint8Array} tsData - MPEG-TS data
|
|
1092
1161
|
* @param {object} options - Optional settings
|
|
1093
1162
|
* @param {function} options.onProgress - Progress callback
|
|
1163
|
+
* @param {number} options.startTime - Start time in seconds (snaps to nearest keyframe)
|
|
1164
|
+
* @param {number} options.endTime - End time in seconds
|
|
1094
1165
|
* @returns {Uint8Array} MP4 data
|
|
1095
1166
|
* @throws {Error} If codecs are unsupported or no video found
|
|
1096
1167
|
*/
|
|
1168
|
+
/**
|
|
1169
|
+
* Analyze MPEG-TS data without converting
|
|
1170
|
+
* Returns duration, keyframe positions, and stream info
|
|
1171
|
+
*
|
|
1172
|
+
* @param {Uint8Array} tsData - MPEG-TS data
|
|
1173
|
+
* @returns {object} Analysis results
|
|
1174
|
+
*/
|
|
1175
|
+
function analyzeTsData(tsData) {
|
|
1176
|
+
const parser = new TSParser();
|
|
1177
|
+
parser.parse(tsData);
|
|
1178
|
+
parser.finalize();
|
|
1179
|
+
|
|
1180
|
+
const PTS_PER_SECOND = 90000;
|
|
1181
|
+
|
|
1182
|
+
// Find keyframes and their timestamps
|
|
1183
|
+
const keyframes = [];
|
|
1184
|
+
for (let i = 0; i < parser.videoAccessUnits.length; i++) {
|
|
1185
|
+
if (isKeyframe(parser.videoAccessUnits[i])) {
|
|
1186
|
+
keyframes.push({
|
|
1187
|
+
index: i,
|
|
1188
|
+
time: parser.videoAccessUnits[i].pts / PTS_PER_SECOND
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Calculate duration
|
|
1194
|
+
const videoDuration = parser.videoPts.length > 0
|
|
1195
|
+
? (Math.max(...parser.videoPts) - Math.min(...parser.videoPts)) / PTS_PER_SECOND
|
|
1196
|
+
: 0;
|
|
1197
|
+
const audioDuration = parser.audioPts.length > 0
|
|
1198
|
+
? (Math.max(...parser.audioPts) - Math.min(...parser.audioPts)) / PTS_PER_SECOND
|
|
1199
|
+
: 0;
|
|
1200
|
+
|
|
1201
|
+
return {
|
|
1202
|
+
duration: Math.max(videoDuration, audioDuration),
|
|
1203
|
+
videoFrames: parser.videoAccessUnits.length,
|
|
1204
|
+
audioFrames: parser.audioAccessUnits.length,
|
|
1205
|
+
keyframes,
|
|
1206
|
+
keyframeCount: keyframes.length,
|
|
1207
|
+
videoCodec: getCodecInfo(parser.videoStreamType).name,
|
|
1208
|
+
audioCodec: getCodecInfo(parser.audioStreamType).name,
|
|
1209
|
+
audioSampleRate: parser.audioSampleRate,
|
|
1210
|
+
audioChannels: parser.audioChannels
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1097
1214
|
function convertTsToMp4(tsData, options = {}) {
|
|
1098
1215
|
const log = options.onProgress || (() => {});
|
|
1099
1216
|
|
|
@@ -1164,6 +1281,29 @@
|
|
|
1164
1281
|
log(`Timestamps normalized: -${offsetMs}ms offset`);
|
|
1165
1282
|
}
|
|
1166
1283
|
|
|
1284
|
+
// Apply time range clipping if specified
|
|
1285
|
+
if (options.startTime !== undefined || options.endTime !== undefined) {
|
|
1286
|
+
const startTime = options.startTime || 0;
|
|
1287
|
+
const endTime = options.endTime !== undefined ? options.endTime : Infinity;
|
|
1288
|
+
|
|
1289
|
+
const clipResult = clipAccessUnits(
|
|
1290
|
+
parser.videoAccessUnits,
|
|
1291
|
+
parser.audioAccessUnits,
|
|
1292
|
+
startTime,
|
|
1293
|
+
endTime
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
parser.videoAccessUnits = clipResult.video;
|
|
1297
|
+
parser.audioAccessUnits = clipResult.audio;
|
|
1298
|
+
|
|
1299
|
+
// Update PTS arrays to match
|
|
1300
|
+
parser.videoPts = clipResult.video.map(au => au.pts);
|
|
1301
|
+
parser.videoDts = clipResult.video.map(au => au.dts);
|
|
1302
|
+
parser.audioPts = clipResult.audio.map(au => au.pts);
|
|
1303
|
+
|
|
1304
|
+
log(`Clipped: ${clipResult.actualStartTime.toFixed(2)}s - ${clipResult.actualEndTime.toFixed(2)}s (${clipResult.video.length} video, ${clipResult.audio.length} audio frames)`);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1167
1307
|
const builder = new MP4Builder(parser);
|
|
1168
1308
|
const { width, height } = builder.getVideoDimensions();
|
|
1169
1309
|
log(`Dimensions: ${width}x${height}`);
|
|
@@ -1611,7 +1751,7 @@
|
|
|
1611
1751
|
toMp4.isMpegTs = isMpegTs;
|
|
1612
1752
|
toMp4.isFmp4 = isFmp4;
|
|
1613
1753
|
toMp4.isStandardMp4 = isStandardMp4;
|
|
1614
|
-
toMp4.version = '1.0.
|
|
1754
|
+
toMp4.version = '1.0.4';
|
|
1615
1755
|
|
|
1616
1756
|
return toMp4;
|
|
1617
1757
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invintusmedia/tomp4",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Convert MPEG-TS and
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "Convert MPEG-TS, fMP4, and HLS streams to MP4 with clipping support - pure JavaScript, zero dependencies",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"module": "src/index.js",
|
|
7
7
|
"types": "src/index.d.ts",
|
|
@@ -39,7 +39,10 @@
|
|
|
39
39
|
"converter",
|
|
40
40
|
"transmux",
|
|
41
41
|
"hls",
|
|
42
|
-
"streaming"
|
|
42
|
+
"streaming",
|
|
43
|
+
"clip",
|
|
44
|
+
"trim",
|
|
45
|
+
"cut"
|
|
43
46
|
],
|
|
44
47
|
"author": "Invintus Media",
|
|
45
48
|
"license": "MIT",
|
package/src/hls.js
CHANGED
|
@@ -104,16 +104,30 @@ function toAbsoluteUrl(relative, base) {
|
|
|
104
104
|
return new URL(relative, base).href;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Represents a segment with duration info
|
|
109
|
+
*/
|
|
110
|
+
class HlsSegment {
|
|
111
|
+
constructor(url, duration, startTime) {
|
|
112
|
+
this.url = url;
|
|
113
|
+
this.duration = duration;
|
|
114
|
+
this.startTime = startTime;
|
|
115
|
+
this.endTime = startTime + duration;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
/**
|
|
108
120
|
* Parse an HLS playlist text
|
|
109
121
|
* @param {string} text - Playlist content
|
|
110
122
|
* @param {string} baseUrl - Base URL for resolving relative paths
|
|
111
|
-
* @returns {{ variants: HlsVariant[], segments:
|
|
123
|
+
* @returns {{ variants: HlsVariant[], segments: HlsSegment[] }}
|
|
112
124
|
*/
|
|
113
125
|
function parsePlaylistText(text, baseUrl) {
|
|
114
126
|
const lines = text.split('\n').map(l => l.trim());
|
|
115
127
|
const variants = [];
|
|
116
128
|
const segments = [];
|
|
129
|
+
let currentDuration = 0;
|
|
130
|
+
let runningTime = 0;
|
|
117
131
|
|
|
118
132
|
for (let i = 0; i < lines.length; i++) {
|
|
119
133
|
const line = lines[i];
|
|
@@ -137,11 +151,23 @@ function parsePlaylistText(text, baseUrl) {
|
|
|
137
151
|
}
|
|
138
152
|
}
|
|
139
153
|
|
|
154
|
+
// Parse segment duration
|
|
155
|
+
if (line.startsWith('#EXTINF:')) {
|
|
156
|
+
const match = line.match(/#EXTINF:([\d.]+)/);
|
|
157
|
+
currentDuration = match ? parseFloat(match[1]) : 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
140
160
|
// Parse media playlist segments
|
|
141
161
|
if (line && !line.startsWith('#')) {
|
|
142
162
|
// It's a segment URL
|
|
143
163
|
if (!lines.some(l => l.startsWith('#EXT-X-STREAM-INF'))) {
|
|
144
|
-
segments.push(
|
|
164
|
+
segments.push(new HlsSegment(
|
|
165
|
+
toAbsoluteUrl(line, baseUrl),
|
|
166
|
+
currentDuration,
|
|
167
|
+
runningTime
|
|
168
|
+
));
|
|
169
|
+
runningTime += currentDuration;
|
|
170
|
+
currentDuration = 0;
|
|
145
171
|
}
|
|
146
172
|
}
|
|
147
173
|
}
|
|
@@ -190,6 +216,8 @@ async function parseHls(url, options = {}) {
|
|
|
190
216
|
* @param {object} [options] - Options
|
|
191
217
|
* @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth number
|
|
192
218
|
* @param {number} [options.maxSegments] - Max segments to download (default: all)
|
|
219
|
+
* @param {number} [options.startTime] - Start time in seconds (downloads segments that overlap)
|
|
220
|
+
* @param {number} [options.endTime] - End time in seconds
|
|
193
221
|
* @param {function} [options.onProgress] - Progress callback
|
|
194
222
|
* @returns {Promise<Uint8Array>} Combined segment data
|
|
195
223
|
*/
|
|
@@ -229,13 +257,35 @@ async function downloadHls(source, options = {}) {
|
|
|
229
257
|
throw new Error('No segments found in playlist');
|
|
230
258
|
}
|
|
231
259
|
|
|
232
|
-
//
|
|
233
|
-
|
|
260
|
+
// Filter by time range if specified
|
|
261
|
+
let toDownload = segments;
|
|
262
|
+
const hasTimeRange = options.startTime !== undefined || options.endTime !== undefined;
|
|
263
|
+
|
|
264
|
+
if (hasTimeRange) {
|
|
265
|
+
const startTime = options.startTime || 0;
|
|
266
|
+
const endTime = options.endTime !== undefined ? options.endTime : Infinity;
|
|
267
|
+
|
|
268
|
+
// Find segments that overlap with the time range
|
|
269
|
+
toDownload = segments.filter(seg => seg.endTime > startTime && seg.startTime < endTime);
|
|
270
|
+
|
|
271
|
+
if (toDownload.length > 0) {
|
|
272
|
+
const actualStart = toDownload[0].startTime;
|
|
273
|
+
const actualEnd = toDownload[toDownload.length - 1].endTime;
|
|
274
|
+
log(`Time range: ${startTime}s-${endTime}s → segments ${actualStart.toFixed(1)}s-${actualEnd.toFixed(1)}s`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Limit segments if specified (applied after time filtering)
|
|
279
|
+
if (options.maxSegments && toDownload.length > options.maxSegments) {
|
|
280
|
+
toDownload = toDownload.slice(0, options.maxSegments);
|
|
281
|
+
}
|
|
282
|
+
|
|
234
283
|
log(`Downloading ${toDownload.length} segment${toDownload.length > 1 ? 's' : ''}...`);
|
|
235
284
|
|
|
236
285
|
// Download all segments in parallel
|
|
237
286
|
const buffers = await Promise.all(
|
|
238
|
-
toDownload.map(async (
|
|
287
|
+
toDownload.map(async (seg, i) => {
|
|
288
|
+
const url = seg.url || seg; // Handle both HlsSegment objects and plain URLs
|
|
239
289
|
const resp = await fetch(url);
|
|
240
290
|
if (!resp.ok) {
|
|
241
291
|
throw new Error(`Segment ${i + 1} failed: ${resp.status}`);
|
|
@@ -254,6 +304,15 @@ async function downloadHls(source, options = {}) {
|
|
|
254
304
|
}
|
|
255
305
|
|
|
256
306
|
log(`Downloaded ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
|
|
307
|
+
|
|
308
|
+
// Return with metadata for precise clipping
|
|
309
|
+
combined._hlsTimeRange = hasTimeRange ? {
|
|
310
|
+
requestedStart: options.startTime || 0,
|
|
311
|
+
requestedEnd: options.endTime,
|
|
312
|
+
actualStart: toDownload[0]?.startTime || 0,
|
|
313
|
+
actualEnd: toDownload[toDownload.length - 1]?.endTime || 0
|
|
314
|
+
} : null;
|
|
315
|
+
|
|
257
316
|
return combined;
|
|
258
317
|
}
|
|
259
318
|
|
|
@@ -270,7 +329,8 @@ function isHlsUrl(url) {
|
|
|
270
329
|
|
|
271
330
|
export {
|
|
272
331
|
HlsStream,
|
|
273
|
-
HlsVariant,
|
|
332
|
+
HlsVariant,
|
|
333
|
+
HlsSegment,
|
|
274
334
|
parseHls,
|
|
275
335
|
downloadHls,
|
|
276
336
|
isHlsUrl,
|
package/src/index.d.ts
CHANGED
|
@@ -46,6 +46,38 @@ declare module '@invintusmedia/tomp4' {
|
|
|
46
46
|
quality?: 'highest' | 'lowest' | number;
|
|
47
47
|
/** Max HLS segments to download */
|
|
48
48
|
maxSegments?: number;
|
|
49
|
+
/** Start time in seconds (snaps to nearest keyframe) */
|
|
50
|
+
startTime?: number;
|
|
51
|
+
/** End time in seconds */
|
|
52
|
+
endTime?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface KeyframeInfo {
|
|
56
|
+
/** Frame index */
|
|
57
|
+
index: number;
|
|
58
|
+
/** Time in seconds */
|
|
59
|
+
time: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AnalysisResult {
|
|
63
|
+
/** Total duration in seconds */
|
|
64
|
+
duration: number;
|
|
65
|
+
/** Number of video frames */
|
|
66
|
+
videoFrames: number;
|
|
67
|
+
/** Number of audio frames */
|
|
68
|
+
audioFrames: number;
|
|
69
|
+
/** Keyframe positions */
|
|
70
|
+
keyframes: KeyframeInfo[];
|
|
71
|
+
/** Number of keyframes */
|
|
72
|
+
keyframeCount: number;
|
|
73
|
+
/** Video codec name */
|
|
74
|
+
videoCodec: string;
|
|
75
|
+
/** Audio codec name */
|
|
76
|
+
audioCodec: string;
|
|
77
|
+
/** Audio sample rate */
|
|
78
|
+
audioSampleRate: number | null;
|
|
79
|
+
/** Audio channel count */
|
|
80
|
+
audioChannels: number | null;
|
|
49
81
|
}
|
|
50
82
|
|
|
51
83
|
/**
|
|
@@ -91,9 +123,13 @@ declare module '@invintusmedia/tomp4' {
|
|
|
91
123
|
|
|
92
124
|
/** Check if URL is an HLS playlist */
|
|
93
125
|
function isHlsUrl(url: string): boolean;
|
|
126
|
+
|
|
127
|
+
/** Analyze MPEG-TS data without converting */
|
|
128
|
+
function analyze(data: Uint8Array): AnalysisResult;
|
|
94
129
|
}
|
|
95
130
|
|
|
96
131
|
export default toMp4;
|
|
97
132
|
export { toMp4 };
|
|
98
133
|
}
|
|
99
134
|
|
|
135
|
+
|
package/src/index.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* NOT SUPPORTED: MPEG-1/2 Video, MP3, AC-3 (require transcoding)
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import { convertTsToMp4 } from './ts-to-mp4.js';
|
|
33
|
+
import { convertTsToMp4, analyzeTsData } from './ts-to-mp4.js';
|
|
34
34
|
import { convertFmp4ToMp4 } from './fmp4-to-mp4.js';
|
|
35
35
|
import { parseHls, downloadHls, isHlsUrl, HlsStream, HlsVariant } from './hls.js';
|
|
36
36
|
|
|
@@ -269,9 +269,24 @@ async function toMp4(input, options = {}) {
|
|
|
269
269
|
throw new Error('Input must be a URL string, HlsStream, Uint8Array, ArrayBuffer, or Blob');
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
// Adjust clip times if we downloaded HLS with a time range
|
|
273
|
+
// The downloaded segments have been normalized to start at 0,
|
|
274
|
+
// so we need to adjust the requested clip times accordingly
|
|
275
|
+
let convertOptions = { ...options };
|
|
276
|
+
if (data._hlsTimeRange && (options.startTime !== undefined || options.endTime !== undefined)) {
|
|
277
|
+
const segmentStart = data._hlsTimeRange.actualStart;
|
|
278
|
+
if (options.startTime !== undefined) {
|
|
279
|
+
convertOptions.startTime = Math.max(0, options.startTime - segmentStart);
|
|
280
|
+
}
|
|
281
|
+
if (options.endTime !== undefined) {
|
|
282
|
+
convertOptions.endTime = options.endTime - segmentStart;
|
|
283
|
+
}
|
|
284
|
+
log(`Adjusted clip: ${convertOptions.startTime?.toFixed(2) || 0}s - ${convertOptions.endTime?.toFixed(2) || '∞'}s (offset: -${segmentStart.toFixed(2)}s)`);
|
|
285
|
+
}
|
|
286
|
+
|
|
272
287
|
// Convert
|
|
273
288
|
log('Converting...');
|
|
274
|
-
const mp4Data = convertData(data,
|
|
289
|
+
const mp4Data = convertData(data, convertOptions);
|
|
275
290
|
|
|
276
291
|
return new Mp4Result(mp4Data, filename);
|
|
277
292
|
}
|
|
@@ -289,8 +304,11 @@ toMp4.parseHls = parseHls;
|
|
|
289
304
|
toMp4.downloadHls = downloadHls;
|
|
290
305
|
toMp4.isHlsUrl = isHlsUrl;
|
|
291
306
|
|
|
307
|
+
// Analysis utilities
|
|
308
|
+
toMp4.analyze = analyzeTsData;
|
|
309
|
+
|
|
292
310
|
// Version (injected at build time for dist, read from package.json for ESM)
|
|
293
|
-
toMp4.version = '1.0.
|
|
311
|
+
toMp4.version = '1.0.4';
|
|
294
312
|
|
|
295
313
|
// Export
|
|
296
314
|
export {
|
|
@@ -298,6 +316,7 @@ export {
|
|
|
298
316
|
Mp4Result,
|
|
299
317
|
convertTsToMp4,
|
|
300
318
|
convertFmp4ToMp4,
|
|
319
|
+
analyzeTsData,
|
|
301
320
|
detectFormat,
|
|
302
321
|
isMpegTs,
|
|
303
322
|
isFmp4,
|
package/src/ts-to-mp4.js
CHANGED
|
@@ -1064,15 +1064,132 @@ function getCodecInfo(streamType) {
|
|
|
1064
1064
|
return STREAM_TYPES[streamType] || { name: `Unknown (0x${streamType?.toString(16)})`, supported: false };
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* Check if a video access unit contains a keyframe (IDR NAL unit)
|
|
1069
|
+
*/
|
|
1070
|
+
function isKeyframe(accessUnit) {
|
|
1071
|
+
for (const nalUnit of accessUnit.nalUnits) {
|
|
1072
|
+
const nalType = nalUnit[0] & 0x1F;
|
|
1073
|
+
if (nalType === 5) return true; // IDR slice
|
|
1074
|
+
}
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Clip access units to a time range, snapping to keyframes
|
|
1080
|
+
* @param {Array} videoAUs - Video access units
|
|
1081
|
+
* @param {Array} audioAUs - Audio access units
|
|
1082
|
+
* @param {number} startTime - Start time in seconds
|
|
1083
|
+
* @param {number} endTime - End time in seconds
|
|
1084
|
+
* @returns {object} Clipped access units and info
|
|
1085
|
+
*/
|
|
1086
|
+
function clipAccessUnits(videoAUs, audioAUs, startTime, endTime) {
|
|
1087
|
+
const PTS_PER_SECOND = 90000;
|
|
1088
|
+
const startPts = startTime * PTS_PER_SECOND;
|
|
1089
|
+
const endPts = endTime * PTS_PER_SECOND;
|
|
1090
|
+
|
|
1091
|
+
// Find keyframe at or before startTime
|
|
1092
|
+
let startIdx = 0;
|
|
1093
|
+
for (let i = 0; i < videoAUs.length; i++) {
|
|
1094
|
+
if (videoAUs[i].pts > startPts) break;
|
|
1095
|
+
if (isKeyframe(videoAUs[i])) startIdx = i;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Find first frame after endTime
|
|
1099
|
+
let endIdx = videoAUs.length;
|
|
1100
|
+
for (let i = startIdx; i < videoAUs.length; i++) {
|
|
1101
|
+
if (videoAUs[i].pts >= endPts) {
|
|
1102
|
+
endIdx = i;
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Clip video
|
|
1108
|
+
const clippedVideo = videoAUs.slice(startIdx, endIdx);
|
|
1109
|
+
|
|
1110
|
+
// Get actual PTS range from clipped video
|
|
1111
|
+
const actualStartPts = clippedVideo.length > 0 ? clippedVideo[0].pts : 0;
|
|
1112
|
+
const actualEndPts = clippedVideo.length > 0 ? clippedVideo[clippedVideo.length - 1].pts : 0;
|
|
1113
|
+
|
|
1114
|
+
// Clip audio to match video time range
|
|
1115
|
+
const clippedAudio = audioAUs.filter(au => au.pts >= actualStartPts && au.pts <= actualEndPts);
|
|
1116
|
+
|
|
1117
|
+
// Normalize timestamps so clip starts at 0
|
|
1118
|
+
const offset = actualStartPts;
|
|
1119
|
+
for (const au of clippedVideo) {
|
|
1120
|
+
au.pts -= offset;
|
|
1121
|
+
au.dts -= offset;
|
|
1122
|
+
}
|
|
1123
|
+
for (const au of clippedAudio) {
|
|
1124
|
+
au.pts -= offset;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return {
|
|
1128
|
+
video: clippedVideo,
|
|
1129
|
+
audio: clippedAudio,
|
|
1130
|
+
actualStartTime: actualStartPts / PTS_PER_SECOND,
|
|
1131
|
+
actualEndTime: actualEndPts / PTS_PER_SECOND,
|
|
1132
|
+
offset
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1067
1136
|
/**
|
|
1068
1137
|
* Convert MPEG-TS data to MP4
|
|
1069
1138
|
*
|
|
1070
1139
|
* @param {Uint8Array} tsData - MPEG-TS data
|
|
1071
1140
|
* @param {object} options - Optional settings
|
|
1072
1141
|
* @param {function} options.onProgress - Progress callback
|
|
1142
|
+
* @param {number} options.startTime - Start time in seconds (snaps to nearest keyframe)
|
|
1143
|
+
* @param {number} options.endTime - End time in seconds
|
|
1073
1144
|
* @returns {Uint8Array} MP4 data
|
|
1074
1145
|
* @throws {Error} If codecs are unsupported or no video found
|
|
1075
1146
|
*/
|
|
1147
|
+
/**
|
|
1148
|
+
* Analyze MPEG-TS data without converting
|
|
1149
|
+
* Returns duration, keyframe positions, and stream info
|
|
1150
|
+
*
|
|
1151
|
+
* @param {Uint8Array} tsData - MPEG-TS data
|
|
1152
|
+
* @returns {object} Analysis results
|
|
1153
|
+
*/
|
|
1154
|
+
export function analyzeTsData(tsData) {
|
|
1155
|
+
const parser = new TSParser();
|
|
1156
|
+
parser.parse(tsData);
|
|
1157
|
+
parser.finalize();
|
|
1158
|
+
|
|
1159
|
+
const PTS_PER_SECOND = 90000;
|
|
1160
|
+
|
|
1161
|
+
// Find keyframes and their timestamps
|
|
1162
|
+
const keyframes = [];
|
|
1163
|
+
for (let i = 0; i < parser.videoAccessUnits.length; i++) {
|
|
1164
|
+
if (isKeyframe(parser.videoAccessUnits[i])) {
|
|
1165
|
+
keyframes.push({
|
|
1166
|
+
index: i,
|
|
1167
|
+
time: parser.videoAccessUnits[i].pts / PTS_PER_SECOND
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Calculate duration
|
|
1173
|
+
const videoDuration = parser.videoPts.length > 0
|
|
1174
|
+
? (Math.max(...parser.videoPts) - Math.min(...parser.videoPts)) / PTS_PER_SECOND
|
|
1175
|
+
: 0;
|
|
1176
|
+
const audioDuration = parser.audioPts.length > 0
|
|
1177
|
+
? (Math.max(...parser.audioPts) - Math.min(...parser.audioPts)) / PTS_PER_SECOND
|
|
1178
|
+
: 0;
|
|
1179
|
+
|
|
1180
|
+
return {
|
|
1181
|
+
duration: Math.max(videoDuration, audioDuration),
|
|
1182
|
+
videoFrames: parser.videoAccessUnits.length,
|
|
1183
|
+
audioFrames: parser.audioAccessUnits.length,
|
|
1184
|
+
keyframes,
|
|
1185
|
+
keyframeCount: keyframes.length,
|
|
1186
|
+
videoCodec: getCodecInfo(parser.videoStreamType).name,
|
|
1187
|
+
audioCodec: getCodecInfo(parser.audioStreamType).name,
|
|
1188
|
+
audioSampleRate: parser.audioSampleRate,
|
|
1189
|
+
audioChannels: parser.audioChannels
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1076
1193
|
export function convertTsToMp4(tsData, options = {}) {
|
|
1077
1194
|
const log = options.onProgress || (() => {});
|
|
1078
1195
|
|
|
@@ -1143,6 +1260,29 @@ export function convertTsToMp4(tsData, options = {}) {
|
|
|
1143
1260
|
log(`Timestamps normalized: -${offsetMs}ms offset`);
|
|
1144
1261
|
}
|
|
1145
1262
|
|
|
1263
|
+
// Apply time range clipping if specified
|
|
1264
|
+
if (options.startTime !== undefined || options.endTime !== undefined) {
|
|
1265
|
+
const startTime = options.startTime || 0;
|
|
1266
|
+
const endTime = options.endTime !== undefined ? options.endTime : Infinity;
|
|
1267
|
+
|
|
1268
|
+
const clipResult = clipAccessUnits(
|
|
1269
|
+
parser.videoAccessUnits,
|
|
1270
|
+
parser.audioAccessUnits,
|
|
1271
|
+
startTime,
|
|
1272
|
+
endTime
|
|
1273
|
+
);
|
|
1274
|
+
|
|
1275
|
+
parser.videoAccessUnits = clipResult.video;
|
|
1276
|
+
parser.audioAccessUnits = clipResult.audio;
|
|
1277
|
+
|
|
1278
|
+
// Update PTS arrays to match
|
|
1279
|
+
parser.videoPts = clipResult.video.map(au => au.pts);
|
|
1280
|
+
parser.videoDts = clipResult.video.map(au => au.dts);
|
|
1281
|
+
parser.audioPts = clipResult.audio.map(au => au.pts);
|
|
1282
|
+
|
|
1283
|
+
log(`Clipped: ${clipResult.actualStartTime.toFixed(2)}s - ${clipResult.actualEndTime.toFixed(2)}s (${clipResult.video.length} video, ${clipResult.audio.length} audio frames)`);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1146
1286
|
const builder = new MP4Builder(parser);
|
|
1147
1287
|
const { width, height } = builder.getVideoDimensions();
|
|
1148
1288
|
log(`Dimensions: ${width}x${height}`);
|