@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.
Files changed (39) hide show
  1. package/dist/Probe.d.ts +441 -29
  2. package/dist/Probe.js +156 -21
  3. package/dist/VideoRenderOptions.d.ts +27 -5
  4. package/dist/VideoRenderOptions.js +1 -1
  5. package/dist/generateTrackFragmentIndexMediabunny.d.ts +3 -0
  6. package/dist/generateTrackFragmentIndexMediabunny.js +343 -0
  7. package/dist/generateTrackMediabunny.d.ts +8 -0
  8. package/dist/generateTrackMediabunny.js +69 -0
  9. package/dist/idempotentTask.js +81 -48
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +2 -2
  12. package/dist/tasks/cacheRemoteAsset.d.ts +0 -1
  13. package/dist/tasks/findOrCreateCaptions.js +1 -1
  14. package/dist/tasks/generateTrack.d.ts +1 -2
  15. package/dist/tasks/generateTrack.js +5 -32
  16. package/dist/tasks/generateTrackFragmentIndex.js +22 -69
  17. package/dist/truncateDecimal.d.ts +1 -0
  18. package/dist/truncateDecimal.js +5 -0
  19. package/package.json +2 -14
  20. package/src/tasks/generateTrack.test.ts +90 -0
  21. package/src/tasks/generateTrack.ts +7 -48
  22. package/src/tasks/generateTrackFragmentIndex.test.ts +115 -0
  23. package/src/tasks/generateTrackFragmentIndex.ts +46 -85
  24. package/types.json +1 -1
  25. package/dist/DecoderManager.d.ts +0 -62
  26. package/dist/DecoderManager.js +0 -114
  27. package/dist/EncodedAsset.d.ts +0 -143
  28. package/dist/EncodedAsset.js +0 -443
  29. package/dist/FrameBuffer.d.ts +0 -62
  30. package/dist/FrameBuffer.js +0 -89
  31. package/dist/MP4File.d.ts +0 -37
  32. package/dist/MP4File.js +0 -209
  33. package/dist/MP4SampleAnalyzer.d.ts +0 -59
  34. package/dist/MP4SampleAnalyzer.js +0 -119
  35. package/dist/SeekStrategy.d.ts +0 -82
  36. package/dist/SeekStrategy.js +0 -101
  37. package/dist/memoize.js +0 -11
  38. package/dist/mp4FileWritable.d.ts +0 -3
  39. 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 };
@@ -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 { mkdir, writeFile } from "node:fs/promises";
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 cachePath$1 = path.join(rootDir, ".cache", `${safePath}.file`);
17
- if (existsSync(absolutePath)) log(`Already cached ${absolutePath}`);
18
- else {
19
- const response = await fetch(absolutePath);
20
- const stream = response.body;
21
- if (response.ok && stream) {
22
- const writeStream = createWriteStream(cachePath$1);
23
- const readable = Readable.fromWeb(stream);
24
- readable.pipe(writeStream);
25
- await new Promise((resolve, reject) => {
26
- readable.on("error", reject);
27
- writeStream.on("error", reject);
28
- writeStream.on("finish", resolve);
29
- });
30
- absolutePath = cachePath$1;
31
- } else throw new Error(`Failed to fetch file from URL ${absolutePath}`);
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 task = runner(absolutePath, ...args);
58
- tasks[key] = task;
59
- log(`Awaiting task for ${key}`);
60
- const result = await task;
61
- if (result instanceof Readable) {
62
- log(`Piping task for ${key} to cache`);
63
- const writeStream = createWriteStream(cachePath);
64
- result.pipe(writeStream);
65
- await new Promise((resolve, reject) => {
66
- result.on("error", reject);
67
- writeStream.on("error", reject);
68
- writeStream.on("finish", resolve);
69
- });
70
- return {
71
- cachePath,
72
- md5Sum: md5
73
- };
74
- }
75
- log(`Writing to ${cachePath}`);
76
- await writeFile(cachePath, result);
77
- return {
78
- md5Sum: md5,
79
- cachePath
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
- 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 { 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
- 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} 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. 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,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 { mp4FileWritable } from "../mp4FileWritable.js";
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
- const readStream = probe.createConformingReadstream();
11
- const mp4File = new MP4File();
12
- log(`Generating track fragment index for ${absolutePath}`);
13
- readStream.pipe(mp4FileWritable(mp4File));
14
- await new Promise((resolve, reject) => {
15
- readStream.on("end", resolve);
16
- readStream.on("error", reject);
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
- const trackByteOffsets = {};
20
- for await (const fragment of mp4File.fragmentIterator()) {
21
- const track = mp4File.getInfo().tracks.find((track$1) => track$1.id === fragment.track);
22
- if (!track) throw new Error("Track not found");
23
- if (fragment.segment === "init") {
24
- trackByteOffsets[fragment.track] = fragment.data.byteLength;
25
- if (track?.type === "video") {
26
- const videoTrack = mp4File.getInfo().videoTracks.find((track$1) => track$1.id === fragment.track);
27
- if (!videoTrack) throw new Error("Video track not found");
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;
@@ -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.17.6-beta.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
- "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 (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 { Probe } from "../Probe.js";
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
- 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} using Mediabunny`);
18
12
 
19
- log(`Generating track for ${absolutePath}`);
13
+ // Use the new Mediabunny-based implementation
14
+ const result = await generateTrackFromPathMediabunny(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));