@editframe/assets 0.17.6-beta.0 → 0.18.3-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/MP4File.js +3 -3
- package/dist/VideoRenderOptions.d.ts +27 -5
- package/dist/VideoRenderOptions.js +1 -1
- package/dist/idempotentTask.js +72 -37
- package/dist/tasks/cacheRemoteAsset.d.ts +0 -1
- package/dist/tasks/generateTrackFragmentIndex.js +17 -0
- package/package.json +1 -1
- package/src/tasks/generateTrackFragmentIndex.ts +32 -0
- package/types.json +1 -1
package/dist/MP4File.js
CHANGED
|
@@ -7,8 +7,8 @@ var MP4File = class extends MP4Box.ISOFile {
|
|
|
7
7
|
this.waitingForSamples = [];
|
|
8
8
|
this._hasSeenLastSamples = false;
|
|
9
9
|
this._arrayBufferFileStart = 0;
|
|
10
|
-
this.readyTimeoutMs = options.readyTimeoutMs ??
|
|
11
|
-
this.sampleWaitTimeoutMs = options.sampleWaitTimeoutMs ??
|
|
10
|
+
this.readyTimeoutMs = options.readyTimeoutMs ?? 1e3;
|
|
11
|
+
this.sampleWaitTimeoutMs = options.sampleWaitTimeoutMs ?? 1e3;
|
|
12
12
|
this.readyPromise = new Promise((resolve, reject) => {
|
|
13
13
|
this.onReady = () => {
|
|
14
14
|
if (this.timeoutId) {
|
|
@@ -26,7 +26,7 @@ var MP4File = class extends MP4Box.ISOFile {
|
|
|
26
26
|
};
|
|
27
27
|
this.timeoutId = setTimeout(() => {
|
|
28
28
|
this.timeoutId = void 0;
|
|
29
|
-
reject(/* @__PURE__ */ new Error(
|
|
29
|
+
reject(/* @__PURE__ */ new Error(`MP4File ready timeout ${this.readyTimeoutMs}ms - file may be invalid or incomplete`));
|
|
30
30
|
}, this.readyTimeoutMs);
|
|
31
31
|
});
|
|
32
32
|
this.readyPromise.catch(() => {});
|
|
@@ -6,11 +6,33 @@ export declare const VideoRenderOptions: z.ZodObject<{
|
|
|
6
6
|
encoderOptions: z.ZodObject<{
|
|
7
7
|
sequenceNumber: z.ZodNumber;
|
|
8
8
|
keyframeIntervalMs: z.ZodNumber;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The nominal start time of the segment in milliseconds.
|
|
11
|
+
* Does not include any padding.
|
|
12
|
+
*/
|
|
10
13
|
fromMs: z.ZodNumber;
|
|
14
|
+
/**
|
|
15
|
+
* The nominal end time of the segment in milliseconds.
|
|
16
|
+
* Does not include any padding.
|
|
17
|
+
*/
|
|
18
|
+
toMs: z.ZodNumber;
|
|
19
|
+
/**
|
|
20
|
+
* Whether or not this segment has audio padding at the start.
|
|
21
|
+
*/
|
|
11
22
|
shouldPadStart: z.ZodBoolean;
|
|
23
|
+
/**
|
|
24
|
+
* Whether or not this segment has audio padding at the end.
|
|
25
|
+
*/
|
|
12
26
|
shouldPadEnd: z.ZodBoolean;
|
|
27
|
+
/**
|
|
28
|
+
* The aligned start time of the segment in microseconds.
|
|
29
|
+
* This includes the padding if any.
|
|
30
|
+
*/
|
|
13
31
|
alignedFromUs: z.ZodNumber;
|
|
32
|
+
/**
|
|
33
|
+
* The aligned end time of the segment in microseconds.
|
|
34
|
+
* This includes the padding if any.
|
|
35
|
+
*/
|
|
14
36
|
alignedToUs: z.ZodNumber;
|
|
15
37
|
isInitSegment: z.ZodBoolean;
|
|
16
38
|
noVideo: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -66,8 +88,8 @@ export declare const VideoRenderOptions: z.ZodObject<{
|
|
|
66
88
|
};
|
|
67
89
|
sequenceNumber: number;
|
|
68
90
|
keyframeIntervalMs: number;
|
|
69
|
-
toMs: number;
|
|
70
91
|
fromMs: number;
|
|
92
|
+
toMs: number;
|
|
71
93
|
shouldPadStart: boolean;
|
|
72
94
|
shouldPadEnd: boolean;
|
|
73
95
|
alignedFromUs: number;
|
|
@@ -91,8 +113,8 @@ export declare const VideoRenderOptions: z.ZodObject<{
|
|
|
91
113
|
};
|
|
92
114
|
sequenceNumber: number;
|
|
93
115
|
keyframeIntervalMs: number;
|
|
94
|
-
toMs: number;
|
|
95
116
|
fromMs: number;
|
|
117
|
+
toMs: number;
|
|
96
118
|
shouldPadStart: boolean;
|
|
97
119
|
shouldPadEnd: boolean;
|
|
98
120
|
alignedFromUs: number;
|
|
@@ -121,8 +143,8 @@ export declare const VideoRenderOptions: z.ZodObject<{
|
|
|
121
143
|
};
|
|
122
144
|
sequenceNumber: number;
|
|
123
145
|
keyframeIntervalMs: number;
|
|
124
|
-
toMs: number;
|
|
125
146
|
fromMs: number;
|
|
147
|
+
toMs: number;
|
|
126
148
|
shouldPadStart: boolean;
|
|
127
149
|
shouldPadEnd: boolean;
|
|
128
150
|
alignedFromUs: number;
|
|
@@ -152,8 +174,8 @@ export declare const VideoRenderOptions: z.ZodObject<{
|
|
|
152
174
|
};
|
|
153
175
|
sequenceNumber: number;
|
|
154
176
|
keyframeIntervalMs: number;
|
|
155
|
-
toMs: number;
|
|
156
177
|
fromMs: number;
|
|
178
|
+
toMs: number;
|
|
157
179
|
shouldPadStart: boolean;
|
|
158
180
|
shouldPadEnd: boolean;
|
|
159
181
|
alignedFromUs: number;
|
|
@@ -6,8 +6,8 @@ const VideoRenderOptions = z.object({
|
|
|
6
6
|
encoderOptions: z.object({
|
|
7
7
|
sequenceNumber: z.number(),
|
|
8
8
|
keyframeIntervalMs: z.number(),
|
|
9
|
-
toMs: z.number(),
|
|
10
9
|
fromMs: z.number(),
|
|
10
|
+
toMs: z.number(),
|
|
11
11
|
shouldPadStart: z.boolean(),
|
|
12
12
|
shouldPadEnd: z.boolean(),
|
|
13
13
|
alignedFromUs: z.number(),
|
package/dist/idempotentTask.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { md5FilePath } from "./md5.js";
|
|
2
2
|
import debug from "debug";
|
|
3
3
|
import { createWriteStream, existsSync } from "node:fs";
|
|
4
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
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,
|
|
@@ -56,28 +85,34 @@ const idempotentTask = ({ label, filename, runner }) => {
|
|
|
56
85
|
log(`Creating new ef:${label} task for ${key}`);
|
|
57
86
|
const task = runner(absolutePath, ...args);
|
|
58
87
|
tasks[key] = task;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
result.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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);
|
|
106
|
+
}
|
|
107
|
+
delete tasks[key];
|
|
70
108
|
return {
|
|
71
|
-
|
|
72
|
-
|
|
109
|
+
md5Sum: md5,
|
|
110
|
+
cachePath
|
|
73
111
|
};
|
|
112
|
+
} catch (error) {
|
|
113
|
+
delete tasks[key];
|
|
114
|
+
throw error;
|
|
74
115
|
}
|
|
75
|
-
log(`Writing to ${cachePath}`);
|
|
76
|
-
await writeFile(cachePath, result);
|
|
77
|
-
return {
|
|
78
|
-
md5Sum: md5,
|
|
79
|
-
cachePath
|
|
80
|
-
};
|
|
81
116
|
};
|
|
82
117
|
};
|
|
83
118
|
export { idempotentTask };
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -7,6 +7,17 @@ import { basename } from "node:path";
|
|
|
7
7
|
const generateTrackFragmentIndexFromPath = async (absolutePath) => {
|
|
8
8
|
const log = debug("ef:generateTrackFragment");
|
|
9
9
|
const probe = await Probe.probePath(absolutePath);
|
|
10
|
+
let startTimeOffsetMs;
|
|
11
|
+
if (probe.format.start_time && Number(probe.format.start_time) !== 0) {
|
|
12
|
+
startTimeOffsetMs = Number(probe.format.start_time) * 1e3;
|
|
13
|
+
log(`Extracted format start_time offset: ${probe.format.start_time}s (${startTimeOffsetMs}ms)`);
|
|
14
|
+
} else {
|
|
15
|
+
const videoStream = probe.streams.find((stream) => stream.codec_type === "video");
|
|
16
|
+
if (videoStream && videoStream.start_time && Number(videoStream.start_time) !== 0) {
|
|
17
|
+
startTimeOffsetMs = Number(videoStream.start_time) * 1e3;
|
|
18
|
+
log(`Extracted video stream start_time offset: ${videoStream.start_time}s (${startTimeOffsetMs}ms)`);
|
|
19
|
+
} else log("No format/stream timing offset found - will detect from composition time");
|
|
20
|
+
}
|
|
10
21
|
const readStream = probe.createConformingReadstream();
|
|
11
22
|
const mp4File = new MP4File();
|
|
12
23
|
log(`Generating track fragment index for ${absolutePath}`);
|
|
@@ -34,6 +45,7 @@ const generateTrackFragmentIndexFromPath = async (absolutePath) => {
|
|
|
34
45
|
sample_count: videoTrack.nb_samples,
|
|
35
46
|
codec: videoTrack.codec,
|
|
36
47
|
duration: videoTrack.duration,
|
|
48
|
+
startTimeOffsetMs,
|
|
37
49
|
initSegment: {
|
|
38
50
|
offset: 0,
|
|
39
51
|
size: fragment.data.byteLength
|
|
@@ -65,6 +77,11 @@ const generateTrackFragmentIndexFromPath = async (absolutePath) => {
|
|
|
65
77
|
const fragmentIndex = trackFragmentIndexes[fragment.track];
|
|
66
78
|
if (trackByteOffsets[fragment.track] === void 0) throw new Error("Fragment index not found");
|
|
67
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
|
+
}
|
|
68
85
|
fragmentIndex.duration += fragment.duration;
|
|
69
86
|
fragmentIndex.segments.push({
|
|
70
87
|
cts: fragment.cts,
|
package/package.json
CHANGED
|
@@ -10,6 +10,25 @@ export const generateTrackFragmentIndexFromPath = async (
|
|
|
10
10
|
) => {
|
|
11
11
|
const log = debug("ef:generateTrackFragment");
|
|
12
12
|
const probe = await Probe.probePath(absolutePath);
|
|
13
|
+
|
|
14
|
+
// Extract timing offset from probe metadata (same logic as processISOBMFF.ts)
|
|
15
|
+
let startTimeOffsetMs: number | undefined;
|
|
16
|
+
|
|
17
|
+
// First check format-level start_time
|
|
18
|
+
if (probe.format.start_time && Number(probe.format.start_time) !== 0) {
|
|
19
|
+
startTimeOffsetMs = Number(probe.format.start_time) * 1000;
|
|
20
|
+
log(`Extracted format start_time offset: ${probe.format.start_time}s (${startTimeOffsetMs}ms)`);
|
|
21
|
+
} else {
|
|
22
|
+
// Check for video stream start_time (more common)
|
|
23
|
+
const videoStream = probe.streams.find(stream => stream.codec_type === 'video');
|
|
24
|
+
if (videoStream && videoStream.start_time && Number(videoStream.start_time) !== 0) {
|
|
25
|
+
startTimeOffsetMs = Number(videoStream.start_time) * 1000;
|
|
26
|
+
log(`Extracted video stream start_time offset: ${videoStream.start_time}s (${startTimeOffsetMs}ms)`);
|
|
27
|
+
} else {
|
|
28
|
+
log("No format/stream timing offset found - will detect from composition time");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
const readStream = probe.createConformingReadstream();
|
|
14
33
|
|
|
15
34
|
const mp4File = new MP4File();
|
|
@@ -50,6 +69,7 @@ export const generateTrackFragmentIndexFromPath = async (
|
|
|
50
69
|
sample_count: videoTrack.nb_samples,
|
|
51
70
|
codec: videoTrack.codec,
|
|
52
71
|
duration: videoTrack.duration,
|
|
72
|
+
startTimeOffsetMs: startTimeOffsetMs, // Add FFmpeg start_time offset
|
|
53
73
|
initSegment: {
|
|
54
74
|
offset: 0,
|
|
55
75
|
size: fragment.data.byteLength,
|
|
@@ -89,6 +109,18 @@ export const generateTrackFragmentIndexFromPath = async (
|
|
|
89
109
|
if (!fragmentIndex) {
|
|
90
110
|
throw new Error("Fragment index not found");
|
|
91
111
|
}
|
|
112
|
+
|
|
113
|
+
// Detect composition time offset from first video segment if no timing offset was found from metadata
|
|
114
|
+
if (fragmentIndex.type === "video" &&
|
|
115
|
+
fragmentIndex.segments.length === 0 &&
|
|
116
|
+
fragmentIndex.startTimeOffsetMs === undefined &&
|
|
117
|
+
fragment.cts > fragment.dts) {
|
|
118
|
+
// Calculate composition time offset in milliseconds
|
|
119
|
+
const compositionOffsetMs = ((fragment.cts - fragment.dts) / fragmentIndex.timescale) * 1000;
|
|
120
|
+
fragmentIndex.startTimeOffsetMs = compositionOffsetMs;
|
|
121
|
+
log(`Detected composition time offset from first video segment: ${compositionOffsetMs}ms (CTS=${fragment.cts}, DTS=${fragment.dts}, timescale=${fragmentIndex.timescale})`);
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
fragmentIndex.duration += fragment.duration;
|
|
93
125
|
fragmentIndex.segments.push({
|
|
94
126
|
cts: fragment.cts,
|