@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.
- package/dist/Probe.d.ts +441 -29
- package/dist/Probe.js +156 -21
- package/dist/generateFragmentIndex.d.ts +3 -0
- package/dist/generateFragmentIndex.js +351 -0
- package/dist/generateSingleTrack.d.ts +8 -0
- package/dist/generateSingleTrack.js +69 -0
- package/dist/idempotentTask.js +34 -36
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/tasks/findOrCreateCaptions.js +1 -1
- package/dist/tasks/generateTrack.d.ts +1 -2
- package/dist/tasks/generateTrack.js +5 -32
- package/dist/tasks/generateTrackFragmentIndex.js +11 -75
- package/dist/truncateDecimal.d.ts +1 -0
- package/dist/truncateDecimal.js +5 -0
- package/package.json +2 -14
- package/src/tasks/generateTrack.test.ts +90 -0
- package/src/tasks/generateTrack.ts +7 -48
- package/src/tasks/generateTrackFragmentIndex.test.ts +115 -0
- package/src/tasks/generateTrackFragmentIndex.ts +27 -98
- package/types.json +1 -1
- package/dist/DecoderManager.d.ts +0 -62
- package/dist/DecoderManager.js +0 -114
- package/dist/EncodedAsset.d.ts +0 -143
- package/dist/EncodedAsset.js +0 -443
- package/dist/FrameBuffer.d.ts +0 -62
- package/dist/FrameBuffer.js +0 -89
- package/dist/MP4File.d.ts +0 -37
- package/dist/MP4File.js +0 -209
- package/dist/MP4SampleAnalyzer.d.ts +0 -59
- package/dist/MP4SampleAnalyzer.js +0 -119
- package/dist/SeekStrategy.d.ts +0 -82
- package/dist/SeekStrategy.js +0 -101
- package/dist/memoize.js +0 -11
- package/dist/mp4FileWritable.d.ts +0 -3
- package/dist/mp4FileWritable.js +0 -19
package/dist/idempotentTask.js
CHANGED
|
@@ -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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@editframe/assets",
|
|
3
|
-
"version": "0.18.
|
|
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
|
-
"
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
13
|
+
// Use the single-track implementation
|
|
14
|
+
const result = await generateSingleTrackFromPath(absolutePath, trackId);
|
|
20
15
|
|
|
21
|
-
//
|
|
22
|
-
|
|
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.
|
|
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
|
+
|