@editframe/assets 0.18.3-beta.0 → 0.18.8-beta.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.
@@ -1,9 +1,9 @@
1
1
  import { md5FilePath } from "./md5.js";
2
- import debug from "debug";
3
2
  import { createWriteStream, existsSync } from "node:fs";
3
+ import debug from "debug";
4
+ import { Readable } from "node:stream";
4
5
  import { mkdir, stat, writeFile } from "node:fs/promises";
5
6
  import path from "node:path";
6
- import { Readable } from "node:stream";
7
7
  const idempotentTask = ({ label, filename, runner }) => {
8
8
  const tasks = {};
9
9
  const downloadTasks = {};
@@ -76,43 +76,41 @@ const idempotentTask = ({ label, filename, runner }) => {
76
76
  const maybeTask = tasks[key];
77
77
  if (maybeTask) {
78
78
  log(`Returning existing ef:${label} task for ${key}`);
79
- await maybeTask;
80
- return {
81
- cachePath,
82
- md5Sum: md5
83
- };
79
+ return await maybeTask;
84
80
  }
85
81
  log(`Creating new ef:${label} task for ${key}`);
86
- const task = runner(absolutePath, ...args);
87
- tasks[key] = task;
88
- try {
89
- log(`Awaiting task for ${key}`);
90
- const result = await task;
91
- if (result instanceof Readable) {
92
- log(`Piping task for ${key} to cache`);
93
- const tempPath = `${cachePath}.tmp`;
94
- const writeStream = createWriteStream(tempPath);
95
- result.pipe(writeStream);
96
- await new Promise((resolve, reject) => {
97
- result.on("error", reject);
98
- writeStream.on("error", reject);
99
- writeStream.on("finish", resolve);
100
- });
101
- const { rename } = await import("node:fs/promises");
102
- await rename(tempPath, cachePath);
103
- } else {
104
- log(`Writing to ${cachePath}`);
105
- await writeFile(cachePath, result);
82
+ const fullTask = (async () => {
83
+ try {
84
+ log(`Awaiting task for ${key}`);
85
+ const result = await runner(absolutePath, ...args);
86
+ if (result instanceof Readable) {
87
+ log(`Piping task for ${key} to cache`);
88
+ const tempPath = `${cachePath}.tmp`;
89
+ const writeStream = createWriteStream(tempPath);
90
+ result.pipe(writeStream);
91
+ await new Promise((resolve, reject) => {
92
+ result.on("error", reject);
93
+ writeStream.on("error", reject);
94
+ writeStream.on("finish", resolve);
95
+ });
96
+ const { rename } = await import("node:fs/promises");
97
+ await rename(tempPath, cachePath);
98
+ } else {
99
+ log(`Writing to ${cachePath}`);
100
+ await writeFile(cachePath, result);
101
+ }
102
+ delete tasks[key];
103
+ return {
104
+ md5Sum: md5,
105
+ cachePath
106
+ };
107
+ } catch (error) {
108
+ delete tasks[key];
109
+ throw error;
106
110
  }
107
- delete tasks[key];
108
- return {
109
- md5Sum: md5,
110
- cachePath
111
- };
112
- } catch (error) {
113
- delete tasks[key];
114
- throw error;
115
- }
111
+ })();
112
+ tasks[key] = fullTask;
113
+ return await fullTask;
116
114
  };
117
115
  };
118
116
  export { idempotentTask };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- export type { StreamSchema, AudioStreamSchema, VideoStreamSchema, TrackSegment, TrackFragmentIndex, AudioTrackFragmentIndex, VideoTrackFragmentIndex, } from './Probe.js';
2
- export { Probe } from './Probe.js';
1
+ export type { StreamSchema, AudioStreamSchema, VideoStreamSchema, ProbeSchema, PacketProbeSchema, TrackSegment, TrackFragmentIndex, AudioTrackFragmentIndex, VideoTrackFragmentIndex, } from './Probe.js';
2
+ export { Probe, PacketProbe } from './Probe.js';
3
+ export { generateFragmentIndex } from './generateFragmentIndex.js';
3
4
  export { md5FilePath, md5Directory, md5ReadStream, md5Buffer } from './md5.js';
4
5
  export { generateTrackFragmentIndex, generateTrackFragmentIndexFromPath, } from './tasks/generateTrackFragmentIndex.js';
5
6
  export { generateTrack, generateTrackFromPath } from './tasks/generateTrack.js';
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- import { Probe } from "./Probe.js";
1
+ import { PacketProbe, Probe } from "./Probe.js";
2
+ import { generateFragmentIndex } from "./generateFragmentIndex.js";
2
3
  import { md5Buffer, md5Directory, md5FilePath, md5ReadStream } from "./md5.js";
3
4
  import { generateTrackFragmentIndex, generateTrackFragmentIndexFromPath } from "./tasks/generateTrackFragmentIndex.js";
4
5
  import { generateTrack, generateTrackFromPath } from "./tasks/generateTrack.js";
5
6
  import { findOrCreateCaptions, generateCaptionDataFromPath } from "./tasks/findOrCreateCaptions.js";
6
7
  import { cacheImage } from "./tasks/cacheImage.js";
7
8
  import { VideoRenderOptions } from "./VideoRenderOptions.js";
8
- export { Probe, VideoRenderOptions, cacheImage, findOrCreateCaptions, generateCaptionDataFromPath, generateTrack, generateTrackFragmentIndex, generateTrackFragmentIndexFromPath, generateTrackFromPath, md5Buffer, md5Directory, md5FilePath, md5ReadStream };
9
+ export { PacketProbe, Probe, VideoRenderOptions, cacheImage, findOrCreateCaptions, generateCaptionDataFromPath, generateFragmentIndex, generateTrack, generateTrackFragmentIndex, generateTrackFragmentIndexFromPath, generateTrackFromPath, md5Buffer, md5Directory, md5FilePath, md5ReadStream };
@@ -1,7 +1,7 @@
1
1
  import { idempotentTask } from "../idempotentTask.js";
2
- import debug from "debug";
3
2
  import { exec } from "node:child_process";
4
3
  import { promisify } from "node:util";
4
+ import debug from "debug";
5
5
  import { basename } from "node:path";
6
6
  const execPromise = promisify(exec);
7
7
  const log = debug("ef:generateCaptions");
@@ -1,4 +1,3 @@
1
- import { PassThrough } from 'node:stream';
2
- export declare const generateTrackFromPath: (absolutePath: string, trackId: number) => Promise<PassThrough>;
1
+ export declare const generateTrackFromPath: (absolutePath: string, trackId: number) => Promise<import('stream').PassThrough>;
3
2
  export declare const generateTrackTask: (rootDir: string, absolutePath: string, trackId: number) => Promise<import('../idempotentTask.js').TaskResult>;
4
3
  export declare const generateTrack: (cacheRoot: string, absolutePath: string, url: string) => Promise<import('../idempotentTask.js').TaskResult>;
@@ -1,39 +1,12 @@
1
- import { MP4File } from "../MP4File.js";
2
- import { Probe } from "../Probe.js";
3
1
  import { idempotentTask } from "../idempotentTask.js";
4
- import { mp4FileWritable } from "../mp4FileWritable.js";
2
+ import { generateSingleTrackFromPath } from "../generateSingleTrack.js";
5
3
  import debug from "debug";
6
4
  import { basename } from "node:path";
7
- import { PassThrough } from "node:stream";
8
5
  const generateTrackFromPath = async (absolutePath, trackId) => {
9
6
  const log = debug("ef:generateTrackFragment");
10
- const probe = await Probe.probePath(absolutePath);
11
- const readStream = probe.createConformingReadstream();
12
- const mp4File = new MP4File();
13
- const trackStream = new PassThrough();
14
- log(`Generating track for ${absolutePath}`);
15
- readStream.pipe(mp4FileWritable(mp4File));
16
- (async () => {
17
- try {
18
- let bytesWritten = 0;
19
- for await (const fragment of mp4File.fragmentIterator()) {
20
- log("Writing fragment", fragment);
21
- if (fragment.track === trackId) {
22
- const written = trackStream.write(Buffer.from(fragment.data), "binary");
23
- if (!written) {
24
- log(`Waiting for drain for track ${trackId}`);
25
- await new Promise((resolve) => trackStream.once("drain", resolve));
26
- }
27
- bytesWritten += fragment.data.byteLength;
28
- }
29
- if (!readStream.readableEnded) await Promise.race([new Promise((resolve) => readStream.once("end", resolve)), new Promise((resolve) => readStream.once("data", resolve))]);
30
- }
31
- trackStream.end();
32
- } catch (error) {
33
- trackStream.destroy(error);
34
- }
35
- })();
36
- return trackStream;
7
+ log(`Generating track ${trackId} for ${absolutePath}`);
8
+ const result = await generateSingleTrackFromPath(absolutePath, trackId);
9
+ return result.stream;
37
10
  };
38
11
  const generateTrackTask = idempotentTask({
39
12
  label: "track",
@@ -43,7 +16,7 @@ const generateTrackTask = idempotentTask({
43
16
  const generateTrack = async (cacheRoot, absolutePath, url) => {
44
17
  try {
45
18
  const trackId = new URL(`http://localhost${url}`).searchParams.get("trackId");
46
- if (trackId === null) throw new Error("No trackId provided. IT must be specified in the query string: ?trackId=0");
19
+ if (trackId === null) throw new Error("No trackId provided. It must be specified in the query string: ?trackId=1 (for video) or ?trackId=2 (for audio)");
47
20
  return await generateTrackTask(cacheRoot, absolutePath, Number(trackId));
48
21
  } catch (error) {
49
22
  console.error(error);
@@ -1,7 +1,6 @@
1
- import { MP4File } from "../MP4File.js";
2
1
  import { Probe } from "../Probe.js";
2
+ import { generateFragmentIndex } from "../generateFragmentIndex.js";
3
3
  import { idempotentTask } from "../idempotentTask.js";
4
- import { mp4FileWritable } from "../mp4FileWritable.js";
5
4
  import debug from "debug";
6
5
  import { basename } from "node:path";
7
6
  const generateTrackFragmentIndexFromPath = async (absolutePath) => {
@@ -18,80 +17,17 @@ const generateTrackFragmentIndexFromPath = async (absolutePath) => {
18
17
  log(`Extracted video stream start_time offset: ${videoStream.start_time}s (${startTimeOffsetMs}ms)`);
19
18
  } else log("No format/stream timing offset found - will detect from composition time");
20
19
  }
21
- const readStream = probe.createConformingReadstream();
22
- const mp4File = new MP4File();
23
- log(`Generating track fragment index for ${absolutePath}`);
24
- readStream.pipe(mp4FileWritable(mp4File));
25
- await new Promise((resolve, reject) => {
26
- readStream.on("end", resolve);
27
- readStream.on("error", reject);
28
- });
20
+ log(`Generating track fragment index for ${absolutePath} using single-track approach`);
29
21
  const trackFragmentIndexes = {};
30
- const trackByteOffsets = {};
31
- for await (const fragment of mp4File.fragmentIterator()) {
32
- const track = mp4File.getInfo().tracks.find((track$1) => track$1.id === fragment.track);
33
- if (!track) throw new Error("Track not found");
34
- if (fragment.segment === "init") {
35
- trackByteOffsets[fragment.track] = fragment.data.byteLength;
36
- if (track?.type === "video") {
37
- const videoTrack = mp4File.getInfo().videoTracks.find((track$1) => track$1.id === fragment.track);
38
- if (!videoTrack) throw new Error("Video track not found");
39
- trackFragmentIndexes[fragment.track] = {
40
- track: fragment.track,
41
- type: "video",
42
- width: videoTrack.video.width,
43
- height: videoTrack.video.height,
44
- timescale: track.timescale,
45
- sample_count: videoTrack.nb_samples,
46
- codec: videoTrack.codec,
47
- duration: videoTrack.duration,
48
- startTimeOffsetMs,
49
- initSegment: {
50
- offset: 0,
51
- size: fragment.data.byteLength
52
- },
53
- segments: []
54
- };
55
- }
56
- if (track?.type === "audio") {
57
- const audioTrack = mp4File.getInfo().audioTracks.find((track$1) => track$1.id === fragment.track);
58
- if (!audioTrack) throw new Error("Audio track not found");
59
- trackFragmentIndexes[fragment.track] = {
60
- track: fragment.track,
61
- type: "audio",
62
- channel_count: audioTrack.audio.channel_count,
63
- sample_rate: audioTrack.audio.sample_rate,
64
- sample_size: audioTrack.audio.sample_size,
65
- sample_count: audioTrack.nb_samples,
66
- timescale: track.timescale,
67
- codec: audioTrack.codec,
68
- duration: audioTrack.duration,
69
- initSegment: {
70
- offset: 0,
71
- size: fragment.data.byteLength
72
- },
73
- segments: []
74
- };
75
- }
76
- } else {
77
- const fragmentIndex = trackFragmentIndexes[fragment.track];
78
- if (trackByteOffsets[fragment.track] === void 0) throw new Error("Fragment index not found");
79
- if (!fragmentIndex) throw new Error("Fragment index not found");
80
- if (fragmentIndex.type === "video" && fragmentIndex.segments.length === 0 && fragmentIndex.startTimeOffsetMs === void 0 && fragment.cts > fragment.dts) {
81
- const compositionOffsetMs = (fragment.cts - fragment.dts) / fragmentIndex.timescale * 1e3;
82
- fragmentIndex.startTimeOffsetMs = compositionOffsetMs;
83
- log(`Detected composition time offset from first video segment: ${compositionOffsetMs}ms (CTS=${fragment.cts}, DTS=${fragment.dts}, timescale=${fragmentIndex.timescale})`);
84
- }
85
- fragmentIndex.duration += fragment.duration;
86
- fragmentIndex.segments.push({
87
- cts: fragment.cts,
88
- dts: fragment.dts,
89
- duration: fragment.duration,
90
- offset: trackByteOffsets[fragment.track],
91
- size: fragment.data.byteLength
92
- });
93
- trackByteOffsets[fragment.track] += fragment.data.byteLength;
94
- }
22
+ for (let streamIndex = 0; streamIndex < probe.streams.length; streamIndex++) {
23
+ const stream = probe.streams[streamIndex];
24
+ if (stream.codec_type !== "audio" && stream.codec_type !== "video") continue;
25
+ const trackId = streamIndex + 1;
26
+ log(`Processing track ${trackId} (${stream.codec_type})`);
27
+ const trackStream = probe.createTrackReadstream(streamIndex);
28
+ const trackIdMapping = { 1: trackId };
29
+ const singleTrackIndexes = await generateFragmentIndex(trackStream, startTimeOffsetMs, trackIdMapping);
30
+ Object.assign(trackFragmentIndexes, singleTrackIndexes);
95
31
  }
96
32
  return trackFragmentIndexes;
97
33
  };
@@ -0,0 +1 @@
1
+ export declare function truncateDecimal(num: number, decimals: number): number;
@@ -0,0 +1,5 @@
1
+ function truncateDecimal(num, decimals) {
2
+ const factor = 10 ** decimals;
3
+ return Math.trunc(num * factor) / factor;
4
+ }
5
+ export { truncateDecimal };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@editframe/assets",
3
- "version": "0.18.3-beta.0",
3
+ "version": "0.18.8-beta.0",
4
4
  "description": "",
5
5
  "exports": {
6
6
  ".": {
@@ -9,18 +9,6 @@
9
9
  "default": "./dist/index.js"
10
10
  }
11
11
  },
12
- "./EncodedAsset.js": {
13
- "import": {
14
- "types": "./dist/EncodedAsset.d.ts",
15
- "default": "./dist/EncodedAsset.js"
16
- }
17
- },
18
- "./MP4File.js": {
19
- "import": {
20
- "types": "./dist/MP4File.d.ts",
21
- "default": "./dist/MP4File.js"
22
- }
23
- },
24
12
  "./types.json": {
25
13
  "import": {
26
14
  "default": "./types.json"
@@ -38,7 +26,7 @@
38
26
  "license": "UNLICENSED",
39
27
  "dependencies": {
40
28
  "debug": "^4.3.5",
41
- "mp4box": "^0.5.2",
29
+ "mediabunny": "^1.5.0",
42
30
  "ora": "^8.0.1",
43
31
  "zod": "^3.23.8"
44
32
  },
@@ -0,0 +1,90 @@
1
+ import { test, describe, assert } from "vitest";
2
+ import { generateTrackFromPath } from "./generateTrack";
3
+ import { Writable } from "node:stream";
4
+ import { pipeline } from "node:stream/promises";
5
+
6
+ describe("generateTrack", () => {
7
+ test("should generate video track", async () => {
8
+ const trackStream = await generateTrackFromPath("test-assets/10s-bars.mp4", 1);
9
+
10
+ // Collect the generated track data
11
+ const chunks: Buffer[] = [];
12
+ const dest = new Writable({
13
+ write(chunk, _encoding, callback) {
14
+ chunks.push(chunk);
15
+ callback();
16
+ }
17
+ });
18
+
19
+ await pipeline(trackStream, dest);
20
+
21
+ // Verify we got MP4 data
22
+ assert.isAbove(chunks.length, 0, "Should generate MP4 chunks");
23
+ const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
24
+ assert.isAbove(totalSize, 1000, "Should generate substantial MP4 data");
25
+
26
+ // Verify it's valid MP4 by checking for ftyp box
27
+ const allData = Buffer.concat(chunks);
28
+ const ftypIndex = allData.indexOf('ftyp');
29
+ assert.isAbove(ftypIndex, -1, "Should contain ftyp box (valid MP4)");
30
+
31
+ console.log(`Generated ${totalSize} bytes for video track`);
32
+ }, 15000);
33
+
34
+ test("should generate audio track", async () => {
35
+ const trackStream = await generateTrackFromPath("test-assets/10s-bars.mp4", 2);
36
+
37
+ // Collect the generated track data
38
+ const chunks: Buffer[] = [];
39
+ const dest = new Writable({
40
+ write(chunk, _encoding, callback) {
41
+ chunks.push(chunk);
42
+ callback();
43
+ }
44
+ });
45
+
46
+ await pipeline(trackStream, dest);
47
+
48
+ // Verify we got MP4 data
49
+ assert.isAbove(chunks.length, 0, "Should generate MP4 chunks");
50
+ const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
51
+ assert.isAbove(totalSize, 1000, "Should generate substantial MP4 data");
52
+
53
+ // Verify it's valid MP4 by checking for ftyp box
54
+ const allData = Buffer.concat(chunks);
55
+ const ftypIndex = allData.indexOf('ftyp');
56
+ assert.isAbove(ftypIndex, -1, "Should contain ftyp box (valid MP4)");
57
+
58
+ console.log(`Generated ${totalSize} bytes for audio track`);
59
+ }, 15000);
60
+
61
+ test("should handle invalid track IDs gracefully", async () => {
62
+ try {
63
+ await generateTrackFromPath("test-assets/frame-count.mp4", 5);
64
+ assert.fail("Should have thrown for invalid track ID");
65
+ } catch (error) {
66
+ assert.instanceOf(error, Error);
67
+ assert.include(error.message, "Track 5 not found");
68
+ }
69
+ });
70
+
71
+ test("should work with single track files", async () => {
72
+ const trackStream = await generateTrackFromPath("test-assets/frame-count.mp4", 1);
73
+
74
+ const chunks: Buffer[] = [];
75
+ const dest = new Writable({
76
+ write(chunk, _encoding, callback) {
77
+ chunks.push(chunk);
78
+ callback();
79
+ }
80
+ });
81
+
82
+ await pipeline(trackStream, dest);
83
+
84
+ const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
85
+ assert.isAbove(totalSize, 1000, "Should generate data for single track file");
86
+
87
+ console.log(`Generated ${totalSize} bytes for single track file`);
88
+ }, 15000);
89
+ });
90
+
@@ -1,61 +1,20 @@
1
1
  import { idempotentTask } from "../idempotentTask.js";
2
- import { MP4File } from "../MP4File.js";
3
2
  import debug from "debug";
4
- import { mp4FileWritable } from "../mp4FileWritable.js";
5
- import { PassThrough } from "node:stream";
6
3
  import { basename } from "node:path";
7
- import { Probe } from "../Probe.js";
4
+ import { generateSingleTrackFromPath } from "../generateSingleTrack.js";
8
5
 
9
6
  export const generateTrackFromPath = async (
10
7
  absolutePath: string,
11
8
  trackId: number,
12
9
  ) => {
13
10
  const log = debug("ef:generateTrackFragment");
14
- const probe = await Probe.probePath(absolutePath);
15
- const readStream = probe.createConformingReadstream();
16
- const mp4File = new MP4File();
17
- const trackStream = new PassThrough();
11
+ log(`Generating track ${trackId} for ${absolutePath}`);
18
12
 
19
- log(`Generating track for ${absolutePath}`);
13
+ // Use the single-track implementation
14
+ const result = await generateSingleTrackFromPath(absolutePath, trackId);
20
15
 
21
- // Set up pipe and processing of fragments
22
- readStream.pipe(mp4FileWritable(mp4File));
23
-
24
- // Process fragments as they become available
25
- (async () => {
26
- try {
27
- let bytesWritten = 0;
28
- for await (const fragment of mp4File.fragmentIterator()) {
29
- log("Writing fragment", fragment);
30
- if (fragment.track === trackId) {
31
- const written = trackStream.write(
32
- Buffer.from(fragment.data),
33
- "binary",
34
- );
35
- if (!written) {
36
- log(`Waiting for drain for track ${trackId}`);
37
- await new Promise((resolve) => trackStream.once("drain", resolve));
38
- }
39
- bytesWritten += fragment.data.byteLength;
40
- }
41
- // There is a race condition where the mp4file writable doesn't correctly wait for the whole readSTream
42
- // Waiting for data/end events seems to fix it
43
- // FIXME: This is a hack, we should fix the root issue in MP4File
44
- if (!readStream.readableEnded) {
45
- await Promise.race([
46
- new Promise((resolve) => readStream.once("end", resolve)),
47
- new Promise((resolve) => readStream.once("data", resolve)),
48
- ]);
49
- }
50
- }
51
-
52
- trackStream.end();
53
- } catch (error) {
54
- trackStream.destroy(error as Error);
55
- }
56
- })();
57
-
58
- return trackStream;
16
+ // Return just the stream for compatibility with existing API
17
+ return result.stream;
59
18
  };
60
19
 
61
20
  export const generateTrackTask = idempotentTask({
@@ -76,7 +35,7 @@ export const generateTrack = async (
76
35
  );
77
36
  if (trackId === null) {
78
37
  throw new Error(
79
- "No trackId provided. IT must be specified in the query string: ?trackId=0",
38
+ "No trackId provided. It must be specified in the query string: ?trackId=1 (for video) or ?trackId=2 (for audio)",
80
39
  );
81
40
  }
82
41
  return await generateTrackTask(cacheRoot, absolutePath, Number(trackId));
@@ -0,0 +1,115 @@
1
+ import { test, describe, assert } from "vitest";
2
+ import { generateTrackFragmentIndexFromPath } from "./generateTrackFragmentIndex";
3
+
4
+ describe("generateTrackFragmentIndex", () => {
5
+ test("should generate fragment index", async () => {
6
+ const fragmentIndex = await generateTrackFragmentIndexFromPath("test-assets/10s-bars.mp4");
7
+
8
+ // Should have multiple tracks
9
+ const trackIds = Object.keys(fragmentIndex).map(Number);
10
+ assert.isAbove(trackIds.length, 0, "Should have tracks");
11
+
12
+ for (const trackId of trackIds) {
13
+ const track = fragmentIndex[trackId]!;
14
+
15
+ // Verify track structure
16
+ assert.oneOf(track.type, ['video', 'audio'], `Track ${trackId} should be video or audio`);
17
+ assert.isNumber(track.track, `Track ${trackId} should have track number`);
18
+ assert.isNumber(track.timescale, `Track ${trackId} should have timescale`);
19
+ assert.isNumber(track.duration, `Track ${trackId} should have duration`);
20
+ assert.isNumber(track.sample_count, `Track ${trackId} should have sample_count`);
21
+ assert.isString(track.codec, `Track ${trackId} should have codec`);
22
+
23
+ // Verify init segment
24
+ assert.equal(track.initSegment.offset, 0, `Track ${trackId} init should start at 0`);
25
+ assert.isAbove(track.initSegment.size, 0, `Track ${trackId} init should have size`);
26
+
27
+ // Verify segments
28
+ assert.isArray(track.segments, `Track ${trackId} should have segments array`);
29
+ assert.isAbove(track.segments.length, 0, `Track ${trackId} should have segments`);
30
+
31
+ // Check each segment
32
+ for (const segment of track.segments) {
33
+ assert.isNumber(segment.cts, `Track ${trackId} segment should have cts`);
34
+ assert.isNumber(segment.dts, `Track ${trackId} segment should have dts`);
35
+ assert.isNumber(segment.duration, `Track ${trackId} segment should have duration`);
36
+ assert.isNumber(segment.offset, `Track ${trackId} segment should have offset`);
37
+ assert.isNumber(segment.size, `Track ${trackId} segment should have size`);
38
+ }
39
+
40
+ // Type-specific checks
41
+ if (track.type === 'video') {
42
+ assert.isNumber(track.width, `Video track ${trackId} should have width`);
43
+ assert.isNumber(track.height, `Video track ${trackId} should have height`);
44
+ } else if (track.type === 'audio') {
45
+ assert.isNumber(track.channel_count, `Audio track ${trackId} should have channel_count`);
46
+ assert.isNumber(track.sample_rate, `Audio track ${trackId} should have sample_rate`);
47
+ assert.isNumber(track.sample_size, `Audio track ${trackId} should have sample_size`);
48
+ }
49
+
50
+ console.log(`Track ${trackId} (${track.type}): ${track.segments.length} segments, ${track.sample_count} samples`);
51
+ }
52
+ }, 20000);
53
+
54
+ test("should handle single track files", async () => {
55
+ const fragmentIndex = await generateTrackFragmentIndexFromPath("test-assets/frame-count.mp4");
56
+
57
+ const trackIds = Object.keys(fragmentIndex).map(Number);
58
+ assert.equal(trackIds.length, 1, "Should have exactly one track");
59
+
60
+ const track = fragmentIndex[trackIds[0]!]!;
61
+ assert.equal(track.type, "video", "Should be video track");
62
+ assert.isAbove(track.segments.length, 0, "Should have segments");
63
+
64
+ console.log(`Single track: ${track.segments.length} segments, ${track.sample_count} samples`);
65
+ }, 15000);
66
+
67
+ test("should generate consistent results with original implementation", async () => {
68
+ // Test that the new implementation produces similar structure to the old one
69
+ const fragmentIndex = await generateTrackFragmentIndexFromPath("test-assets/bars-n-tone.mp4");
70
+
71
+ const trackIds = Object.keys(fragmentIndex).map(Number);
72
+ assert.equal(trackIds.length, 2, "Should have video and audio tracks");
73
+
74
+ // Should have both video and audio
75
+ const videoTrack = Object.values(fragmentIndex).find(t => t.type === 'video');
76
+ const audioTrack = Object.values(fragmentIndex).find(t => t.type === 'audio');
77
+
78
+ assert.exists(videoTrack, "Should have video track");
79
+ assert.exists(audioTrack, "Should have audio track");
80
+
81
+ // Video track checks
82
+ assert.isAbove(videoTrack.width, 0, "Video should have width");
83
+ assert.isAbove(videoTrack.height, 0, "Video should have height");
84
+ assert.isAbove(videoTrack.segments.length, 0, "Video should have segments");
85
+
86
+ // Audio track checks
87
+ assert.isAbove(audioTrack.channel_count, 0, "Audio should have channels");
88
+ assert.isAbove(audioTrack.sample_rate, 0, "Audio should have sample rate");
89
+ assert.isAbove(audioTrack.segments.length, 0, "Audio should have segments");
90
+
91
+ console.log(`Consistent results: video ${videoTrack.segments.length} segments, audio ${audioTrack.segments.length} segments`);
92
+ }, 20000);
93
+
94
+ test("should preserve timing offset detection", async () => {
95
+ // Test with a file that might have timing offsets
96
+ const fragmentIndex = await generateTrackFragmentIndexFromPath("test-assets/frame-count.mp4");
97
+
98
+ const trackIds = Object.keys(fragmentIndex).map(Number);
99
+ const track = fragmentIndex[trackIds[0]!]!;
100
+
101
+ if (track.type === 'video' && track.startTimeOffsetMs !== undefined) {
102
+ assert.isNumber(track.startTimeOffsetMs, "Should have timing offset");
103
+ console.log(`Detected timing offset: ${track.startTimeOffsetMs}ms`);
104
+ } else {
105
+ console.log("No timing offset detected (expected for this file)");
106
+ }
107
+
108
+ // Should still have valid timing data
109
+ assert.isAbove(track.duration, 0, "Should have positive duration");
110
+ for (const segment of track.segments) {
111
+ assert.isAbove(segment.duration, 0, "Each segment should have positive duration");
112
+ }
113
+ }, 15000);
114
+ });
115
+