@editframe/assets 0.16.8-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.
@@ -0,0 +1,82 @@
1
+ import type * as MP4Box from "mp4box";
2
+ /**
3
+ * Interface for the minimal FrameBuffer API needed by SeekStrategy
4
+ */
5
+ interface FrameBufferLike {
6
+ findByTimestamp(timestamp: number): VideoFrame | undefined;
7
+ }
8
+ /**
9
+ * State information needed for seeking decisions
10
+ */
11
+ export interface SeekState {
12
+ /** Current position in the samples array for decoding */
13
+ sampleCursor: number;
14
+ /** Position of the last successfully decoded frame output */
15
+ outCursor: number;
16
+ /** Optional frame buffer for checking cached frames */
17
+ frameBuffer?: FrameBufferLike;
18
+ }
19
+ /**
20
+ * SeekStrategy encapsulates the critical seeking decision logic extracted from VideoAsset.
21
+ *
22
+ * **CRITICAL**: This class preserves the exact seeking behavior and decoder flush conditions
23
+ * from the original VideoAsset implementation to maintain "warm" decoder state performance.
24
+ *
25
+ * The logic here directly controls when the decoder must be flushed, which is the most
26
+ * performance-critical aspect of frame-accurate seeking.
27
+ */
28
+ export declare class SeekStrategy {
29
+ /**
30
+ * Determines if seeking will skip picture groups (GOPs), requiring a decoder flush.
31
+ *
32
+ * This is an EXACT extraction of the original `seekingWillSkipPictureGroup` logic.
33
+ *
34
+ * @param state Current seek state with cursor positions
35
+ * @param targetSample The sample we want to seek to
36
+ * @param allSamples All samples in display order
37
+ * @returns true if seeking will cross more than one sync frame (GOP boundary)
38
+ */
39
+ seekingWillSkipPictureGroup(state: SeekState, targetSample: MP4Box.Sample, allSamples: MP4Box.Sample[]): boolean;
40
+ /**
41
+ * Determines if seeking will go backwards in time, requiring a decoder flush.
42
+ *
43
+ * This is an EXACT extraction of the original `seekingWillGoBackwards` logic.
44
+ *
45
+ * @param state Current seek state with cursor positions and frame buffer
46
+ * @param targetSample The sample we want to seek to
47
+ * @param displayOrderedSamples Samples sorted by composition timestamp
48
+ * @returns true if seeking backwards and target frame is not cached
49
+ */
50
+ seekingWillGoBackwards(state: SeekState, targetSample: MP4Box.Sample, displayOrderedSamples: MP4Box.Sample[]): boolean;
51
+ /**
52
+ * Finds the sync sample at or before the target sample number.
53
+ *
54
+ * This is an EXACT extraction of the sync sample finding logic from the original
55
+ * `seekToTime` method. Used when decoder flush is required to find optimal restart point.
56
+ *
57
+ * @param targetSample The sample we want to seek to
58
+ * @param allSamples All samples in the video
59
+ * @returns The sample number of the sync frame to start decoding from
60
+ * @throws Error if no sync sample found when traversing backwards
61
+ */
62
+ findSyncSampleBefore(targetSample: MP4Box.Sample, allSamples: MP4Box.Sample[]): number;
63
+ /**
64
+ * The master decision function that determines if the decoder should be flushed.
65
+ *
66
+ * This consolidates the EXACT flush decision logic from the original VideoAsset.seekToTime().
67
+ * The decoder is flushed ONLY when:
68
+ * 1. Seeking will skip picture groups (crosses multiple GOP boundaries), OR
69
+ * 2. Seeking backwards and target frame is not in cache
70
+ *
71
+ * **CRITICAL**: This preserves the sophisticated flush minimization that keeps
72
+ * the decoder "warm" for optimal performance.
73
+ *
74
+ * @param state Current seek state
75
+ * @param targetSample The sample we want to seek to
76
+ * @param allSamples All samples in the video
77
+ * @param displayOrderedSamples Samples sorted by composition timestamp
78
+ * @returns true if decoder should be flushed before seeking
79
+ */
80
+ shouldFlushDecoder(state: SeekState, targetSample: MP4Box.Sample, allSamples: MP4Box.Sample[], displayOrderedSamples?: MP4Box.Sample[]): boolean;
81
+ }
82
+ export {};
@@ -0,0 +1,101 @@
1
+ /**
2
+ * SeekStrategy encapsulates the critical seeking decision logic extracted from VideoAsset.
3
+ *
4
+ * **CRITICAL**: This class preserves the exact seeking behavior and decoder flush conditions
5
+ * from the original VideoAsset implementation to maintain "warm" decoder state performance.
6
+ *
7
+ * The logic here directly controls when the decoder must be flushed, which is the most
8
+ * performance-critical aspect of frame-accurate seeking.
9
+ */
10
+ var SeekStrategy = class {
11
+ /**
12
+ * Determines if seeking will skip picture groups (GOPs), requiring a decoder flush.
13
+ *
14
+ * This is an EXACT extraction of the original `seekingWillSkipPictureGroup` logic.
15
+ *
16
+ * @param state Current seek state with cursor positions
17
+ * @param targetSample The sample we want to seek to
18
+ * @param allSamples All samples in display order
19
+ * @returns true if seeking will cross more than one sync frame (GOP boundary)
20
+ */
21
+ seekingWillSkipPictureGroup(state, targetSample, allSamples) {
22
+ let start = state.sampleCursor;
23
+ const end = targetSample.number;
24
+ let syncFrameCrossings = 0;
25
+ while (start <= end) {
26
+ const sample = allSamples[start];
27
+ if (!sample) break;
28
+ if (sample.is_sync) {
29
+ if (syncFrameCrossings > 1) return true;
30
+ syncFrameCrossings++;
31
+ }
32
+ start++;
33
+ }
34
+ return false;
35
+ }
36
+ /**
37
+ * Determines if seeking will go backwards in time, requiring a decoder flush.
38
+ *
39
+ * This is an EXACT extraction of the original `seekingWillGoBackwards` logic.
40
+ *
41
+ * @param state Current seek state with cursor positions and frame buffer
42
+ * @param targetSample The sample we want to seek to
43
+ * @param displayOrderedSamples Samples sorted by composition timestamp
44
+ * @returns true if seeking backwards and target frame is not cached
45
+ */
46
+ seekingWillGoBackwards(state, targetSample, displayOrderedSamples) {
47
+ const targetIndex = displayOrderedSamples.indexOf(targetSample);
48
+ const targetInCache = state.frameBuffer?.findByTimestamp(targetSample.cts);
49
+ const atEnd = state.sampleCursor === displayOrderedSamples.length - 1;
50
+ if (atEnd) return false;
51
+ if (targetInCache) return false;
52
+ return state.outCursor > targetIndex;
53
+ }
54
+ /**
55
+ * Finds the sync sample at or before the target sample number.
56
+ *
57
+ * This is an EXACT extraction of the sync sample finding logic from the original
58
+ * `seekToTime` method. Used when decoder flush is required to find optimal restart point.
59
+ *
60
+ * @param targetSample The sample we want to seek to
61
+ * @param allSamples All samples in the video
62
+ * @returns The sample number of the sync frame to start decoding from
63
+ * @throws Error if no sync sample found when traversing backwards
64
+ */
65
+ findSyncSampleBefore(targetSample, allSamples) {
66
+ let syncSampleNumber = targetSample.number;
67
+ while (syncSampleNumber >= 0) {
68
+ const sample = allSamples[syncSampleNumber];
69
+ if (!sample) break;
70
+ if (sample.is_sync) return syncSampleNumber;
71
+ syncSampleNumber--;
72
+ }
73
+ throw new Error("No sync sample found when traversing backwards");
74
+ }
75
+ /**
76
+ * The master decision function that determines if the decoder should be flushed.
77
+ *
78
+ * This consolidates the EXACT flush decision logic from the original VideoAsset.seekToTime().
79
+ * The decoder is flushed ONLY when:
80
+ * 1. Seeking will skip picture groups (crosses multiple GOP boundaries), OR
81
+ * 2. Seeking backwards and target frame is not in cache
82
+ *
83
+ * **CRITICAL**: This preserves the sophisticated flush minimization that keeps
84
+ * the decoder "warm" for optimal performance.
85
+ *
86
+ * @param state Current seek state
87
+ * @param targetSample The sample we want to seek to
88
+ * @param allSamples All samples in the video
89
+ * @param displayOrderedSamples Samples sorted by composition timestamp
90
+ * @returns true if decoder should be flushed before seeking
91
+ */
92
+ shouldFlushDecoder(state, targetSample, allSamples, displayOrderedSamples) {
93
+ const targetInCache = state.frameBuffer?.findByTimestamp(targetSample.cts);
94
+ if (targetInCache) return false;
95
+ const orderedSamples = displayOrderedSamples || allSamples;
96
+ if (this.seekingWillSkipPictureGroup(state, targetSample, allSamples)) return true;
97
+ if (this.seekingWillGoBackwards(state, targetSample, orderedSamples)) return true;
98
+ return false;
99
+ }
100
+ };
101
+ export { SeekStrategy };
@@ -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;
@@ -1,36 +1,34 @@
1
1
  import { z } from "zod";
2
2
  const VideoRenderOptions = z.object({
3
- mode: z.enum(["canvas", "screenshot"]),
4
- strategy: z.enum(["v1", "v2"]),
5
- showFrameBox: z.boolean().optional(),
6
- encoderOptions: z.object({
7
- sequenceNumber: z.number(),
8
- keyframeIntervalMs: z.number(),
9
- toMs: z.number(),
10
- fromMs: z.number(),
11
- shouldPadStart: z.boolean(),
12
- shouldPadEnd: z.boolean(),
13
- alignedFromUs: z.number(),
14
- alignedToUs: z.number(),
15
- isInitSegment: z.boolean(),
16
- noVideo: z.boolean().optional(),
17
- noAudio: z.boolean().optional(),
18
- video: z.object({
19
- width: z.number(),
20
- height: z.number(),
21
- framerate: z.number(),
22
- codec: z.string(),
23
- bitrate: z.number()
24
- }),
25
- audio: z.object({
26
- sampleRate: z.number(),
27
- codec: z.string(),
28
- numberOfChannels: z.number(),
29
- bitrate: z.number()
30
- })
31
- }),
32
- fetchHost: z.string()
3
+ mode: z.enum(["canvas", "screenshot"]),
4
+ strategy: z.enum(["v1", "v2"]),
5
+ showFrameBox: z.boolean().optional(),
6
+ encoderOptions: z.object({
7
+ sequenceNumber: z.number(),
8
+ keyframeIntervalMs: z.number(),
9
+ fromMs: z.number(),
10
+ toMs: z.number(),
11
+ shouldPadStart: z.boolean(),
12
+ shouldPadEnd: z.boolean(),
13
+ alignedFromUs: z.number(),
14
+ alignedToUs: z.number(),
15
+ isInitSegment: z.boolean(),
16
+ noVideo: z.boolean().optional(),
17
+ noAudio: z.boolean().optional(),
18
+ video: z.object({
19
+ width: z.number(),
20
+ height: z.number(),
21
+ framerate: z.number(),
22
+ codec: z.string(),
23
+ bitrate: z.number()
24
+ }),
25
+ audio: z.object({
26
+ sampleRate: z.number(),
27
+ codec: z.string(),
28
+ numberOfChannels: z.number(),
29
+ bitrate: z.number()
30
+ })
31
+ }),
32
+ fetchHost: z.string()
33
33
  });
34
- export {
35
- VideoRenderOptions
36
- };
34
+ export { VideoRenderOptions };
@@ -1,83 +1,118 @@
1
- import { existsSync, createWriteStream } from "node:fs";
2
- import path from "node:path";
3
1
  import { md5FilePath } from "./md5.js";
4
2
  import debug from "debug";
5
- import { mkdir, writeFile } from "node:fs/promises";
3
+ import { createWriteStream, existsSync } from "node:fs";
4
+ import { mkdir, stat, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
6
  import { Readable } from "node:stream";
7
- const idempotentTask = ({
8
- label,
9
- filename,
10
- runner
11
- }) => {
12
- const tasks = {};
13
- return async (rootDir, absolutePath, ...args) => {
14
- const log = debug(`ef:${label}`);
15
- const cacheDirRoot = path.join(rootDir, ".cache");
16
- await mkdir(cacheDirRoot, { recursive: true });
17
- log(`Running ef:${label} task for ${absolutePath} in ${rootDir}`);
18
- if (absolutePath.includes("http")) {
19
- const safePath = absolutePath.replace(/[^a-zA-Z0-9]/g, "_");
20
- const cachePath2 = path.join(rootDir, ".cache", `${safePath}.file`);
21
- if (existsSync(absolutePath)) {
22
- log(`Already cached ${absolutePath}`);
23
- } else {
24
- const response = await fetch(absolutePath);
25
- const stream = response.body;
26
- if (response.ok && stream) {
27
- const writeStream = createWriteStream(cachePath2);
28
- const readable = Readable.fromWeb(stream);
29
- readable.pipe(writeStream);
30
- await new Promise((resolve, reject) => {
31
- readable.on("error", reject);
32
- writeStream.on("error", reject);
33
- writeStream.on("finish", resolve);
34
- });
35
- absolutePath = cachePath2;
36
- } else {
37
- throw new Error(`Failed to fetch file from URL ${absolutePath}`);
38
- }
39
- }
40
- }
41
- const md5 = await md5FilePath(absolutePath);
42
- const cacheDir = path.join(cacheDirRoot, md5);
43
- log(`Cache dir: ${cacheDir}`);
44
- await mkdir(cacheDir, { recursive: true });
45
- const cachePath = path.join(cacheDir, filename(absolutePath, ...args));
46
- const key = cachePath;
47
- if (existsSync(cachePath)) {
48
- log(`Returning cached ef:${label} task for ${key}`);
49
- return { cachePath, md5Sum: md5 };
50
- }
51
- const maybeTask = tasks[key];
52
- if (maybeTask) {
53
- log(`Returning existing ef:${label} task for ${key}`);
54
- await maybeTask;
55
- return { cachePath, md5Sum: md5 };
56
- }
57
- log(`Creating new ef:${label} task for ${key}`);
58
- const task = runner(absolutePath, ...args);
59
- tasks[key] = task;
60
- log(`Awaiting task for ${key}`);
61
- const result = await task;
62
- if (result instanceof Readable) {
63
- log(`Piping task for ${key} to cache`);
64
- const writeStream = createWriteStream(cachePath);
65
- result.pipe(writeStream);
66
- await new Promise((resolve, reject) => {
67
- result.on("error", reject);
68
- writeStream.on("error", reject);
69
- writeStream.on("finish", resolve);
70
- });
71
- return { cachePath, md5Sum: md5 };
72
- }
73
- log(`Writing to ${cachePath}`);
74
- await writeFile(cachePath, result);
75
- return {
76
- md5Sum: md5,
77
- cachePath
78
- };
79
- };
80
- };
81
- export {
82
- idempotentTask
7
+ const idempotentTask = ({ label, filename, runner }) => {
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
+ };
18
+ return async (rootDir, absolutePath, ...args) => {
19
+ const log = debug(`ef:${label}`);
20
+ const cacheDirRoot = path.join(rootDir, ".cache");
21
+ await mkdir(cacheDirRoot, { recursive: true });
22
+ log(`Running ef:${label} task for ${absolutePath} in ${rootDir}`);
23
+ if (absolutePath.includes("http")) {
24
+ const safePath = absolutePath.replace(/[^a-zA-Z0-9]/g, "_");
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];
61
+ }
62
+ }
63
+ const md5 = await md5FilePath(absolutePath);
64
+ const cacheDir = path.join(cacheDirRoot, md5);
65
+ log(`Cache dir: ${cacheDir}`);
66
+ await mkdir(cacheDir, { recursive: true });
67
+ const cachePath = path.join(cacheDir, filename(absolutePath, ...args));
68
+ const key = cachePath;
69
+ if (existsSync(cachePath) && await isValidCacheFile(cachePath)) {
70
+ log(`Returning cached ef:${label} task for ${key}`);
71
+ return {
72
+ cachePath,
73
+ md5Sum: md5
74
+ };
75
+ }
76
+ const maybeTask = tasks[key];
77
+ if (maybeTask) {
78
+ log(`Returning existing ef:${label} task for ${key}`);
79
+ await maybeTask;
80
+ return {
81
+ cachePath,
82
+ md5Sum: md5
83
+ };
84
+ }
85
+ log(`Creating new ef:${label} task for ${key}`);
86
+ const task = runner(absolutePath, ...args);
87
+ tasks[key] = task;
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];
108
+ return {
109
+ md5Sum: md5,
110
+ cachePath
111
+ };
112
+ } catch (error) {
113
+ delete tasks[key];
114
+ throw error;
115
+ }
116
+ };
83
117
  };
118
+ export { idempotentTask };
package/dist/index.js CHANGED
@@ -5,18 +5,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 {
9
- Probe,
10
- VideoRenderOptions,
11
- cacheImage,
12
- findOrCreateCaptions,
13
- generateCaptionDataFromPath,
14
- generateTrack,
15
- generateTrackFragmentIndex,
16
- generateTrackFragmentIndexFromPath,
17
- generateTrackFromPath,
18
- md5Buffer,
19
- md5Directory,
20
- md5FilePath,
21
- md5ReadStream
22
- };
8
+ export { Probe, VideoRenderOptions, cacheImage, findOrCreateCaptions, generateCaptionDataFromPath, generateTrack, generateTrackFragmentIndex, generateTrackFragmentIndexFromPath, generateTrackFromPath, md5Buffer, md5Directory, md5FilePath, md5ReadStream };
package/dist/md5.js CHANGED
@@ -4,63 +4,47 @@ import { join } from "node:path";
4
4
  import crypto from "node:crypto";
5
5
  import ora from "ora";
6
6
  async function md5Directory(directory, spinner) {
7
- const shouldEndSpinner = !spinner;
8
- spinner ||= ora("⚡️ Calculating MD5").start();
9
- spinner.suffixText = directory;
10
- const files = await readdir(directory, { withFileTypes: true });
11
- const hashes = await Promise.all(
12
- files.map(async (file) => {
13
- const filePath = join(directory, file.name);
14
- if (file.isDirectory()) {
15
- return md5Directory(filePath, spinner);
16
- }
17
- spinner.suffixText = filePath;
18
- return md5FilePath(filePath);
19
- })
20
- );
21
- const hash = crypto.createHash("md5");
22
- for (const fileHash of hashes) {
23
- hash.update(fileHash);
24
- }
25
- if (shouldEndSpinner) {
26
- spinner.succeed("MD5 calculated");
27
- spinner.suffixText = directory;
28
- }
29
- return addDashesToUUID(hash.digest("hex"));
7
+ const shouldEndSpinner = !spinner;
8
+ spinner ||= ora("⚡️ Calculating MD5").start();
9
+ spinner.suffixText = directory;
10
+ const files = await readdir(directory, { withFileTypes: true });
11
+ const hashes = await Promise.all(files.map(async (file) => {
12
+ const filePath = join(directory, file.name);
13
+ if (file.isDirectory()) return md5Directory(filePath, spinner);
14
+ spinner.suffixText = filePath;
15
+ return md5FilePath(filePath);
16
+ }));
17
+ const hash = crypto.createHash("md5");
18
+ for (const fileHash of hashes) hash.update(fileHash);
19
+ if (shouldEndSpinner) {
20
+ spinner.succeed("MD5 calculated");
21
+ spinner.suffixText = directory;
22
+ }
23
+ return addDashesToUUID(hash.digest("hex"));
30
24
  }
31
25
  async function md5FilePath(filePath) {
32
- const readStream = createReadStream(filePath);
33
- return md5ReadStream(readStream);
26
+ const readStream = createReadStream(filePath);
27
+ return md5ReadStream(readStream);
34
28
  }
35
29
  function md5ReadStream(readStream) {
36
- return new Promise((resolve, reject) => {
37
- const hash = crypto.createHash("md5");
38
- readStream.on("data", (data) => {
39
- hash.update(data);
40
- });
41
- readStream.on("error", reject);
42
- readStream.on("end", () => {
43
- resolve(addDashesToUUID(hash.digest("hex")));
44
- });
45
- });
30
+ return new Promise((resolve, reject) => {
31
+ const hash = crypto.createHash("md5");
32
+ readStream.on("data", (data) => {
33
+ hash.update(data);
34
+ });
35
+ readStream.on("error", reject);
36
+ readStream.on("end", () => {
37
+ resolve(addDashesToUUID(hash.digest("hex")));
38
+ });
39
+ });
46
40
  }
47
41
  function md5Buffer(buffer) {
48
- const hash = crypto.createHash("md5");
49
- hash.update(buffer);
50
- return addDashesToUUID(hash.digest("hex"));
42
+ const hash = crypto.createHash("md5");
43
+ hash.update(buffer);
44
+ return addDashesToUUID(hash.digest("hex"));
51
45
  }
52
46
  function addDashesToUUID(uuidWithoutDashes) {
53
- if (uuidWithoutDashes.length !== 32) {
54
- throw new Error("Invalid UUID without dashes. Expected 32 characters.");
55
- }
56
- return (
57
- // biome-ignore lint/style/useTemplate: using a template makes a long line
58
- uuidWithoutDashes.slice(0, 8) + "-" + uuidWithoutDashes.slice(8, 12) + "-" + uuidWithoutDashes.slice(12, 16) + "-" + uuidWithoutDashes.slice(16, 20) + "-" + uuidWithoutDashes.slice(20, 32)
59
- );
47
+ if (uuidWithoutDashes.length !== 32) throw new Error("Invalid UUID without dashes. Expected 32 characters.");
48
+ return uuidWithoutDashes.slice(0, 8) + "-" + uuidWithoutDashes.slice(8, 12) + "-" + uuidWithoutDashes.slice(12, 16) + "-" + uuidWithoutDashes.slice(16, 20) + "-" + uuidWithoutDashes.slice(20, 32);
60
49
  }
61
- export {
62
- md5Buffer,
63
- md5Directory,
64
- md5FilePath,
65
- md5ReadStream
66
- };
50
+ export { md5Buffer, md5Directory, md5FilePath, md5ReadStream };