@invintusmedia/tomp4 1.0.1 → 1.0.3

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 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
  &nbsp;
@@ -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
- ### use the result
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
- // download it
46
- mp4.download('video.mp4')
61
+ const info = toMp4.analyze(tsData)
47
62
 
48
- // play it
49
- video.src = mp4.toURL()
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
- // get the bytes
52
- mp4.data // Uint8Array
53
- mp4.toArrayBuffer()
54
- mp4.toBlob()
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
  &nbsp;
@@ -85,11 +108,10 @@ works in both. ~50kb minified.
85
108
 
86
109
  ```html
87
110
  <script type="module">
88
- import toMp4 from './dist/tomp4.js'
111
+ import toMp4 from '@invintusmedia/tomp4'
89
112
  </script>
90
113
  ```
91
114
 
92
115
  &nbsp;
93
116
 
94
117
  MIT
95
-
package/dist/tomp4.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * toMp4.js v1.0.1
2
+ * toMp4.js v1.0.3
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.1';
1754
+ toMp4.version = '1.0.3';
1615
1755
 
1616
1756
  return toMp4;
1617
1757
  });
package/package.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "name": "@invintusmedia/tomp4",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Convert MPEG-TS and fMP4 streams to standard MP4 - pure JavaScript, zero dependencies",
5
- "main": "dist/tomp4.js",
5
+ "main": "src/index.js",
6
6
  "module": "src/index.js",
7
+ "types": "src/index.d.ts",
7
8
  "type": "module",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./src/index.js",
12
+ "require": "./dist/tomp4.js",
13
+ "types": "./src/index.d.ts"
14
+ }
15
+ },
8
16
  "files": [
9
17
  "src",
10
18
  "dist"
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: string[] }}
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(toAbsoluteUrl(line, baseUrl));
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
- // Limit segments if specified
233
- const toDownload = options.maxSegments ? segments.slice(0, options.maxSegments) : segments;
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 (url, i) => {
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 ADDED
@@ -0,0 +1,135 @@
1
+ declare module '@invintusmedia/tomp4' {
2
+ export interface Mp4Result {
3
+ /** Raw MP4 data */
4
+ data: Uint8Array;
5
+ /** Suggested filename */
6
+ filename: string;
7
+ /** Size in bytes */
8
+ size: number;
9
+ /** Human-readable size (e.g. "2.5 MB") */
10
+ sizeFormatted: string;
11
+ /** Get as Blob */
12
+ toBlob(): Blob;
13
+ /** Get as object URL for video.src */
14
+ toURL(): string;
15
+ /** Revoke the object URL to free memory */
16
+ revokeURL(): void;
17
+ /** Trigger browser download */
18
+ download(filename?: string): void;
19
+ /** Get as ArrayBuffer */
20
+ toArrayBuffer(): ArrayBuffer;
21
+ }
22
+
23
+ export interface HlsVariant {
24
+ url: string;
25
+ bandwidth: number;
26
+ resolution?: string;
27
+ width?: number;
28
+ height?: number;
29
+ codecs?: string;
30
+ }
31
+
32
+ export interface HlsStream {
33
+ masterUrl: string;
34
+ variants: HlsVariant[];
35
+ qualities: string[];
36
+ select(quality: string | number): HlsStream;
37
+ segments: string[];
38
+ }
39
+
40
+ export interface ToMp4Options {
41
+ /** Progress callback */
42
+ onProgress?: (message: string) => void;
43
+ /** Suggested filename for downloads */
44
+ filename?: string;
45
+ /** HLS quality: 'highest', 'lowest', or bandwidth number */
46
+ quality?: 'highest' | 'lowest' | number;
47
+ /** Max HLS segments to download */
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;
81
+ }
82
+
83
+ /**
84
+ * Convert video to MP4
85
+ * @param input - URL, HLS stream, or video data
86
+ * @param options - Conversion options
87
+ */
88
+ function toMp4(
89
+ input: string | Uint8Array | ArrayBuffer | Blob | HlsStream,
90
+ options?: ToMp4Options
91
+ ): Promise<Mp4Result>;
92
+
93
+ namespace toMp4 {
94
+ /** Library version */
95
+ const version: string;
96
+
97
+ /** Convert MPEG-TS data to MP4 */
98
+ function fromTs(data: Uint8Array | ArrayBuffer, options?: ToMp4Options): Mp4Result;
99
+
100
+ /** Convert fMP4 data to MP4 */
101
+ function fromFmp4(data: Uint8Array | ArrayBuffer): Mp4Result;
102
+
103
+ /** Detect format of video data */
104
+ function detectFormat(data: Uint8Array): 'mpegts' | 'fmp4' | 'mp4' | 'unknown';
105
+
106
+ /** Check if data is MPEG-TS */
107
+ function isMpegTs(data: Uint8Array): boolean;
108
+
109
+ /** Check if data is fMP4 */
110
+ function isFmp4(data: Uint8Array): boolean;
111
+
112
+ /** Check if data is standard MP4 */
113
+ function isStandardMp4(data: Uint8Array): boolean;
114
+
115
+ /** Parse HLS playlist */
116
+ function parseHls(url: string): Promise<HlsStream>;
117
+
118
+ /** Download and combine HLS segments */
119
+ function downloadHls(
120
+ input: string | HlsStream,
121
+ options?: ToMp4Options
122
+ ): Promise<Uint8Array>;
123
+
124
+ /** Check if URL is an HLS playlist */
125
+ function isHlsUrl(url: string): boolean;
126
+
127
+ /** Analyze MPEG-TS data without converting */
128
+ function analyze(data: Uint8Array): AnalysisResult;
129
+ }
130
+
131
+ export default toMp4;
132
+ export { toMp4 };
133
+ }
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
 
@@ -289,8 +289,11 @@ toMp4.parseHls = parseHls;
289
289
  toMp4.downloadHls = downloadHls;
290
290
  toMp4.isHlsUrl = isHlsUrl;
291
291
 
292
+ // Analysis utilities
293
+ toMp4.analyze = analyzeTsData;
294
+
292
295
  // Version (injected at build time for dist, read from package.json for ESM)
293
- toMp4.version = '1.0.1';
296
+ toMp4.version = '1.0.3';
294
297
 
295
298
  // Export
296
299
  export {
@@ -298,6 +301,7 @@ export {
298
301
  Mp4Result,
299
302
  convertTsToMp4,
300
303
  convertFmp4ToMp4,
304
+ analyzeTsData,
301
305
  detectFormat,
302
306
  isMpegTs,
303
307
  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}`);