@editframe/assets 0.17.6-beta.0 → 0.18.7-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/VideoRenderOptions.d.ts +27 -5
- package/dist/VideoRenderOptions.js +1 -1
- package/dist/generateTrackFragmentIndexMediabunny.d.ts +3 -0
- package/dist/generateTrackFragmentIndexMediabunny.js +343 -0
- package/dist/generateTrackMediabunny.d.ts +8 -0
- package/dist/generateTrackMediabunny.js +69 -0
- package/dist/idempotentTask.js +81 -48
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/tasks/cacheRemoteAsset.d.ts +0 -1
- 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 +22 -69
- 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 +46 -85
- 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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Probe } from "./Probe.js";
|
|
2
|
+
import { idempotentTask } from "./idempotentTask.js";
|
|
3
|
+
import { generateTrackFragmentIndexMediabunny } from "./generateTrackFragmentIndexMediabunny.js";
|
|
4
|
+
import debug from "debug";
|
|
5
|
+
import { basename } from "node:path";
|
|
6
|
+
import { PassThrough } from "node:stream";
|
|
7
|
+
const log = debug("ef:generateTrackMediabunny");
|
|
8
|
+
const generateTrackFromPathMediabunny = async (absolutePath, trackId) => {
|
|
9
|
+
log(`Generating track ${trackId} for ${absolutePath}`);
|
|
10
|
+
const probe = await Probe.probePath(absolutePath);
|
|
11
|
+
const streamIndex = trackId - 1;
|
|
12
|
+
if (streamIndex < 0 || streamIndex >= probe.streams.length) throw new Error(`Track ${trackId} not found (valid tracks: 1-${probe.streams.length})`);
|
|
13
|
+
const trackStream = probe.createTrackReadstream(streamIndex);
|
|
14
|
+
const outputStream = new PassThrough();
|
|
15
|
+
const indexStream = new PassThrough();
|
|
16
|
+
trackStream.pipe(outputStream, { end: false });
|
|
17
|
+
trackStream.pipe(indexStream);
|
|
18
|
+
let sourceStreamEnded = false;
|
|
19
|
+
trackStream.on("end", () => {
|
|
20
|
+
sourceStreamEnded = true;
|
|
21
|
+
});
|
|
22
|
+
trackStream.on("error", (error) => {
|
|
23
|
+
outputStream.destroy(error);
|
|
24
|
+
indexStream.destroy(error);
|
|
25
|
+
});
|
|
26
|
+
const trackIdMapping = { 1: trackId };
|
|
27
|
+
const fragmentIndexPromise = generateTrackFragmentIndexMediabunny(indexStream, void 0, trackIdMapping);
|
|
28
|
+
fragmentIndexPromise.then(() => {
|
|
29
|
+
if (sourceStreamEnded) outputStream.end();
|
|
30
|
+
else trackStream.once("end", () => {
|
|
31
|
+
outputStream.end();
|
|
32
|
+
});
|
|
33
|
+
}).catch((error) => {
|
|
34
|
+
outputStream.destroy(error);
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
stream: outputStream,
|
|
38
|
+
fragmentIndex: fragmentIndexPromise
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const generateTrackTaskMediabunny = idempotentTask({
|
|
42
|
+
label: "track-mediabunny",
|
|
43
|
+
filename: (absolutePath, trackId) => `${basename(absolutePath)}.track-${trackId}.mp4`,
|
|
44
|
+
runner: async (absolutePath, trackId) => {
|
|
45
|
+
const result = await generateTrackFromPathMediabunny(absolutePath, trackId);
|
|
46
|
+
const finalStream = new PassThrough();
|
|
47
|
+
let streamEnded = false;
|
|
48
|
+
let fragmentIndexCompleted = false;
|
|
49
|
+
const checkCompletion = () => {
|
|
50
|
+
if (streamEnded && fragmentIndexCompleted) finalStream.end();
|
|
51
|
+
};
|
|
52
|
+
result.stream.pipe(finalStream, { end: false });
|
|
53
|
+
result.stream.on("end", () => {
|
|
54
|
+
streamEnded = true;
|
|
55
|
+
checkCompletion();
|
|
56
|
+
});
|
|
57
|
+
result.stream.on("error", (error) => {
|
|
58
|
+
finalStream.destroy(error);
|
|
59
|
+
});
|
|
60
|
+
result.fragmentIndex.then(() => {
|
|
61
|
+
fragmentIndexCompleted = true;
|
|
62
|
+
checkCompletion();
|
|
63
|
+
}).catch((error) => {
|
|
64
|
+
finalStream.destroy(error);
|
|
65
|
+
});
|
|
66
|
+
return finalStream;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
export { generateTrackFromPathMediabunny };
|
package/dist/idempotentTask.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { md5FilePath } from "./md5.js";
|
|
2
|
-
import debug from "debug";
|
|
3
2
|
import { createWriteStream, existsSync } from "node:fs";
|
|
4
|
-
import
|
|
3
|
+
import debug from "debug";
|
|
4
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
7
7
|
const idempotentTask = ({ label, filename, runner }) => {
|
|
8
8
|
const tasks = {};
|
|
9
|
+
const downloadTasks = {};
|
|
10
|
+
const isValidCacheFile = async (filePath, allowEmpty = false) => {
|
|
11
|
+
try {
|
|
12
|
+
const stats = await stat(filePath);
|
|
13
|
+
return allowEmpty || stats.size > 0;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
9
18
|
return async (rootDir, absolutePath, ...args) => {
|
|
10
19
|
const log = debug(`ef:${label}`);
|
|
11
20
|
const cacheDirRoot = path.join(rootDir, ".cache");
|
|
@@ -13,22 +22,42 @@ const idempotentTask = ({ label, filename, runner }) => {
|
|
|
13
22
|
log(`Running ef:${label} task for ${absolutePath} in ${rootDir}`);
|
|
14
23
|
if (absolutePath.includes("http")) {
|
|
15
24
|
const safePath = absolutePath.replace(/[^a-zA-Z0-9]/g, "_");
|
|
16
|
-
const
|
|
17
|
-
if (existsSync(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
const downloadCachePath = path.join(rootDir, ".cache", `${safePath}.file`);
|
|
26
|
+
if (existsSync(downloadCachePath) && await isValidCacheFile(downloadCachePath, true)) {
|
|
27
|
+
log(`Already cached ${absolutePath}`);
|
|
28
|
+
absolutePath = downloadCachePath;
|
|
29
|
+
} else {
|
|
30
|
+
const downloadKey = absolutePath;
|
|
31
|
+
if (!downloadTasks[downloadKey]) {
|
|
32
|
+
log(`Starting download for ${absolutePath}`);
|
|
33
|
+
downloadTasks[downloadKey] = (async () => {
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(absolutePath);
|
|
36
|
+
if (!response.ok) throw new Error(`Failed to fetch file from URL ${absolutePath}: ${response.status} ${response.statusText}`);
|
|
37
|
+
const stream = response.body;
|
|
38
|
+
if (!stream) throw new Error(`No response body for URL ${absolutePath}`);
|
|
39
|
+
const tempPath = `${downloadCachePath}.tmp`;
|
|
40
|
+
const writeStream = createWriteStream(tempPath);
|
|
41
|
+
const readable = Readable.fromWeb(stream);
|
|
42
|
+
readable.pipe(writeStream);
|
|
43
|
+
await new Promise((resolve, reject) => {
|
|
44
|
+
readable.on("error", reject);
|
|
45
|
+
writeStream.on("error", reject);
|
|
46
|
+
writeStream.on("finish", resolve);
|
|
47
|
+
});
|
|
48
|
+
const { rename } = await import("node:fs/promises");
|
|
49
|
+
await rename(tempPath, downloadCachePath);
|
|
50
|
+
log(`Download completed for ${absolutePath}`);
|
|
51
|
+
return downloadCachePath;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
log(`Download failed for ${absolutePath}: ${error}`);
|
|
54
|
+
delete downloadTasks[downloadKey];
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
}
|
|
59
|
+
absolutePath = await downloadTasks[downloadKey];
|
|
60
|
+
delete downloadTasks[downloadKey];
|
|
32
61
|
}
|
|
33
62
|
}
|
|
34
63
|
const md5 = await md5FilePath(absolutePath);
|
|
@@ -37,7 +66,7 @@ const idempotentTask = ({ label, filename, runner }) => {
|
|
|
37
66
|
await mkdir(cacheDir, { recursive: true });
|
|
38
67
|
const cachePath = path.join(cacheDir, filename(absolutePath, ...args));
|
|
39
68
|
const key = cachePath;
|
|
40
|
-
if (existsSync(cachePath)) {
|
|
69
|
+
if (existsSync(cachePath) && await isValidCacheFile(cachePath)) {
|
|
41
70
|
log(`Returning cached ef:${label} task for ${key}`);
|
|
42
71
|
return {
|
|
43
72
|
cachePath,
|
|
@@ -47,37 +76,41 @@ const idempotentTask = ({ label, filename, runner }) => {
|
|
|
47
76
|
const maybeTask = tasks[key];
|
|
48
77
|
if (maybeTask) {
|
|
49
78
|
log(`Returning existing ef:${label} task for ${key}`);
|
|
50
|
-
await maybeTask;
|
|
51
|
-
return {
|
|
52
|
-
cachePath,
|
|
53
|
-
md5Sum: md5
|
|
54
|
-
};
|
|
79
|
+
return await maybeTask;
|
|
55
80
|
}
|
|
56
81
|
log(`Creating new ef:${label} task for ${key}`);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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;
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
tasks[key] = fullTask;
|
|
113
|
+
return await fullTask;
|
|
81
114
|
};
|
|
82
115
|
};
|
|
83
116
|
export { idempotentTask };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
3
|
export { md5FilePath, md5Directory, md5ReadStream, md5Buffer } from './md5.js';
|
|
4
4
|
export { generateTrackFragmentIndex, generateTrackFragmentIndexFromPath, } from './tasks/generateTrackFragmentIndex.js';
|
|
5
5
|
export { generateTrack, generateTrackFromPath } from './tasks/generateTrack.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Probe } from "./Probe.js";
|
|
1
|
+
import { PacketProbe, Probe } from "./Probe.js";
|
|
2
2
|
import { md5Buffer, md5Directory, md5FilePath, md5ReadStream } from "./md5.js";
|
|
3
3
|
import { generateTrackFragmentIndex, generateTrackFragmentIndexFromPath } from "./tasks/generateTrackFragmentIndex.js";
|
|
4
4
|
import { generateTrack, generateTrackFromPath } from "./tasks/generateTrack.js";
|
|
5
5
|
import { findOrCreateCaptions, generateCaptionDataFromPath } from "./tasks/findOrCreateCaptions.js";
|
|
6
6
|
import { cacheImage } from "./tasks/cacheImage.js";
|
|
7
7
|
import { VideoRenderOptions } from "./VideoRenderOptions.js";
|
|
8
|
-
export { Probe, VideoRenderOptions, cacheImage, findOrCreateCaptions, generateCaptionDataFromPath, generateTrack, generateTrackFragmentIndex, generateTrackFragmentIndexFromPath, generateTrackFromPath, md5Buffer, md5Directory, md5FilePath, md5ReadStream };
|
|
8
|
+
export { PacketProbe, Probe, VideoRenderOptions, cacheImage, findOrCreateCaptions, generateCaptionDataFromPath, generateTrack, generateTrackFragmentIndex, generateTrackFragmentIndexFromPath, generateTrackFromPath, md5Buffer, md5Directory, md5FilePath, md5ReadStream };
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -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 { generateTrackFromPathMediabunny } from "../generateTrackMediabunny.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} using Mediabunny`);
|
|
8
|
+
const result = await generateTrackFromPathMediabunny(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,80 +1,33 @@
|
|
|
1
|
-
import { MP4File } from "../MP4File.js";
|
|
2
1
|
import { Probe } from "../Probe.js";
|
|
3
2
|
import { idempotentTask } from "../idempotentTask.js";
|
|
4
|
-
import {
|
|
3
|
+
import { generateTrackFragmentIndexMediabunny } from "../generateTrackFragmentIndexMediabunny.js";
|
|
5
4
|
import debug from "debug";
|
|
6
5
|
import { basename } from "node:path";
|
|
7
6
|
const generateTrackFragmentIndexFromPath = async (absolutePath) => {
|
|
8
7
|
const log = debug("ef:generateTrackFragment");
|
|
9
8
|
const probe = await Probe.probePath(absolutePath);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
let startTimeOffsetMs;
|
|
10
|
+
if (probe.format.start_time && Number(probe.format.start_time) !== 0) {
|
|
11
|
+
startTimeOffsetMs = Number(probe.format.start_time) * 1e3;
|
|
12
|
+
log(`Extracted format start_time offset: ${probe.format.start_time}s (${startTimeOffsetMs}ms)`);
|
|
13
|
+
} else {
|
|
14
|
+
const videoStream = probe.streams.find((stream) => stream.codec_type === "video");
|
|
15
|
+
if (videoStream && videoStream.start_time && Number(videoStream.start_time) !== 0) {
|
|
16
|
+
startTimeOffsetMs = Number(videoStream.start_time) * 1e3;
|
|
17
|
+
log(`Extracted video stream start_time offset: ${videoStream.start_time}s (${startTimeOffsetMs}ms)`);
|
|
18
|
+
} else log("No format/stream timing offset found - will detect from composition time");
|
|
19
|
+
}
|
|
20
|
+
log(`Generating track fragment index for ${absolutePath} using single-track approach`);
|
|
18
21
|
const trackFragmentIndexes = {};
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
trackFragmentIndexes[fragment.track] = {
|
|
29
|
-
track: fragment.track,
|
|
30
|
-
type: "video",
|
|
31
|
-
width: videoTrack.video.width,
|
|
32
|
-
height: videoTrack.video.height,
|
|
33
|
-
timescale: track.timescale,
|
|
34
|
-
sample_count: videoTrack.nb_samples,
|
|
35
|
-
codec: videoTrack.codec,
|
|
36
|
-
duration: videoTrack.duration,
|
|
37
|
-
initSegment: {
|
|
38
|
-
offset: 0,
|
|
39
|
-
size: fragment.data.byteLength
|
|
40
|
-
},
|
|
41
|
-
segments: []
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
if (track?.type === "audio") {
|
|
45
|
-
const audioTrack = mp4File.getInfo().audioTracks.find((track$1) => track$1.id === fragment.track);
|
|
46
|
-
if (!audioTrack) throw new Error("Audio track not found");
|
|
47
|
-
trackFragmentIndexes[fragment.track] = {
|
|
48
|
-
track: fragment.track,
|
|
49
|
-
type: "audio",
|
|
50
|
-
channel_count: audioTrack.audio.channel_count,
|
|
51
|
-
sample_rate: audioTrack.audio.sample_rate,
|
|
52
|
-
sample_size: audioTrack.audio.sample_size,
|
|
53
|
-
sample_count: audioTrack.nb_samples,
|
|
54
|
-
timescale: track.timescale,
|
|
55
|
-
codec: audioTrack.codec,
|
|
56
|
-
duration: audioTrack.duration,
|
|
57
|
-
initSegment: {
|
|
58
|
-
offset: 0,
|
|
59
|
-
size: fragment.data.byteLength
|
|
60
|
-
},
|
|
61
|
-
segments: []
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
} else {
|
|
65
|
-
const fragmentIndex = trackFragmentIndexes[fragment.track];
|
|
66
|
-
if (trackByteOffsets[fragment.track] === void 0) throw new Error("Fragment index not found");
|
|
67
|
-
if (!fragmentIndex) throw new Error("Fragment index not found");
|
|
68
|
-
fragmentIndex.duration += fragment.duration;
|
|
69
|
-
fragmentIndex.segments.push({
|
|
70
|
-
cts: fragment.cts,
|
|
71
|
-
dts: fragment.dts,
|
|
72
|
-
duration: fragment.duration,
|
|
73
|
-
offset: trackByteOffsets[fragment.track],
|
|
74
|
-
size: fragment.data.byteLength
|
|
75
|
-
});
|
|
76
|
-
trackByteOffsets[fragment.track] += fragment.data.byteLength;
|
|
77
|
-
}
|
|
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 generateTrackFragmentIndexMediabunny(trackStream, startTimeOffsetMs, trackIdMapping);
|
|
30
|
+
Object.assign(trackFragmentIndexes, singleTrackIndexes);
|
|
78
31
|
}
|
|
79
32
|
return trackFragmentIndexes;
|
|
80
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.
|
|
3
|
+
"version": "0.18.7-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 (updated with Mediabunny)", () => {
|
|
7
|
+
test("should generate video track using Mediabunny backend", 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 using Mediabunny backend", 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 { generateTrackFromPathMediabunny } from "../generateTrackMediabunny.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} using Mediabunny`);
|
|
18
12
|
|
|
19
|
-
|
|
13
|
+
// Use the new Mediabunny-based implementation
|
|
14
|
+
const result = await generateTrackFromPathMediabunny(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));
|