@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 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 ?? 100;
11
- this.sampleWaitTimeoutMs = options.sampleWaitTimeoutMs ?? 100;
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("MP4File ready timeout - file may be invalid or incomplete"));
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
- toMs: z.ZodNumber;
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(),
@@ -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 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,
@@ -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
- 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
- });
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
- cachePath,
72
- md5Sum: md5
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@editframe/assets",
3
- "version": "0.17.6-beta.0",
3
+ "version": "0.18.3-beta.0",
4
4
  "description": "",
5
5
  "exports": {
6
6
  ".": {
@@ -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,