peasy-video 0.1.1
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/LICENSE +21 -0
- package/README.md +307 -0
- package/dist/index.d.ts +264 -0
- package/dist/index.js +218 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peasy Tools
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# peasy-video-js
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/peasy-video-js)
|
|
4
|
+
[](https://www.typescriptlang.org/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Video processing library for Node.js -- trim, resize, rotate, extract audio, generate thumbnails, convert to GIF, concatenate clips, adjust speed, and reverse video. FFmpeg-powered, TypeScript-first with full type safety. Handles MP4, WebM, MKV, AVI, MOV, and any container format supported by FFmpeg.
|
|
8
|
+
|
|
9
|
+
Built from [PeasyVideo](https://peasyvideo.com), a free online video toolkit with 15 browser-based tools for trimming, resizing, format conversion, thumbnail extraction, and GIF creation.
|
|
10
|
+
|
|
11
|
+
> **Try the interactive tools at [peasyvideo.com](https://peasyvideo.com)** -- video trimming, resizing, audio extraction, GIF conversion, and thumbnail generation
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<img src="demo.gif" alt="peasy-video-js demo — video info, thumbnail extraction, and format operations in Node.js" width="800">
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
## Table of Contents
|
|
18
|
+
|
|
19
|
+
- [Prerequisites](#prerequisites)
|
|
20
|
+
- [Install](#install)
|
|
21
|
+
- [Quick Start](#quick-start)
|
|
22
|
+
- [What You Can Do](#what-you-can-do)
|
|
23
|
+
- [Video Info & Metadata](#video-info--metadata)
|
|
24
|
+
- [Trimming & Concatenation](#trimming--concatenation)
|
|
25
|
+
- [Resize & Transform](#resize--transform)
|
|
26
|
+
- [Audio Extraction](#audio-extraction)
|
|
27
|
+
- [Thumbnails](#thumbnails)
|
|
28
|
+
- [GIF Conversion](#gif-conversion)
|
|
29
|
+
- [Speed & Reverse](#speed--reverse)
|
|
30
|
+
- [TypeScript Types](#typescript-types)
|
|
31
|
+
- [API Reference](#api-reference)
|
|
32
|
+
- [Also Available for Python](#also-available-for-python)
|
|
33
|
+
- [Peasy Developer Tools](#peasy-developer-tools)
|
|
34
|
+
- [License](#license)
|
|
35
|
+
|
|
36
|
+
## Prerequisites
|
|
37
|
+
|
|
38
|
+
peasy-video-js uses FFmpeg under the hood. Install it before using this library:
|
|
39
|
+
|
|
40
|
+
| Platform | Command |
|
|
41
|
+
|----------|---------|
|
|
42
|
+
| **macOS** | `brew install ffmpeg` |
|
|
43
|
+
| **Ubuntu/Debian** | `sudo apt install ffmpeg` |
|
|
44
|
+
| **Fedora/RHEL** | `sudo dnf install ffmpeg-free` |
|
|
45
|
+
| **Windows** | `choco install ffmpeg` |
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install peasy-video-js
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { info, trim, resize, thumbnail, videoToGif } from "peasy-video-js";
|
|
57
|
+
|
|
58
|
+
// Get video metadata
|
|
59
|
+
const meta = await info("movie.mp4");
|
|
60
|
+
console.log(meta.width, meta.height, meta.duration); // 1920 1080 7200
|
|
61
|
+
|
|
62
|
+
// Trim to a 30-second clip
|
|
63
|
+
const clip = await trim("movie.mp4", { start: 60, duration: 30 });
|
|
64
|
+
|
|
65
|
+
// Resize to 720p
|
|
66
|
+
const resized = await resize("movie.mp4", { width: 1280, height: 720 });
|
|
67
|
+
|
|
68
|
+
// Extract a thumbnail at 5 seconds
|
|
69
|
+
const thumb = await thumbnail("movie.mp4", { time: 5 });
|
|
70
|
+
|
|
71
|
+
// Convert a clip to GIF
|
|
72
|
+
const gif = await videoToGif("clip.mp4", { fps: 15, width: 480 });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## What You Can Do
|
|
76
|
+
|
|
77
|
+
### Video Info & Metadata
|
|
78
|
+
|
|
79
|
+
Extract comprehensive metadata from video files without decoding frames. FFprobe reads container headers and stream information to report resolution, frame rate, codec, bitrate, duration, and whether an audio track is present.
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import { info } from "peasy-video-js";
|
|
83
|
+
|
|
84
|
+
// Extract video metadata using FFprobe
|
|
85
|
+
const meta = await info("presentation.mp4");
|
|
86
|
+
console.log(meta.width); // 1920
|
|
87
|
+
console.log(meta.height); // 1080
|
|
88
|
+
console.log(meta.fps); // 30
|
|
89
|
+
console.log(meta.duration); // 3600.5 (seconds)
|
|
90
|
+
console.log(meta.codec); // "h264"
|
|
91
|
+
console.log(meta.hasAudio); // true
|
|
92
|
+
console.log(meta.bitrate); // 5000000
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Learn more: [Peasy Video Tools](https://peasyvideo.com) · [Glossary](https://peasytools.com/glossary/)
|
|
96
|
+
|
|
97
|
+
### Trimming & Concatenation
|
|
98
|
+
|
|
99
|
+
Video trimming extracts a segment using keyframe-accurate seeking. Concatenation joins multiple clips sequentially using the FFmpeg concat demuxer, maintaining codec compatibility across segments.
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import { trim, concatenate } from "peasy-video-js";
|
|
103
|
+
|
|
104
|
+
// Extract a 30-second clip starting at 1 minute
|
|
105
|
+
const clip = await trim("lecture.mp4", { start: 60, duration: 30 });
|
|
106
|
+
|
|
107
|
+
// Trim from start to end time
|
|
108
|
+
const intro = await trim("movie.mp4", { start: 0, end: 15 });
|
|
109
|
+
|
|
110
|
+
// Join multiple clips into one video
|
|
111
|
+
const combined = await concatenate([
|
|
112
|
+
"chapter1.mp4",
|
|
113
|
+
"chapter2.mp4",
|
|
114
|
+
"chapter3.mp4",
|
|
115
|
+
]);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Learn more: [Peasy Video Tools](https://peasyvideo.com) · [Glossary](https://peasytools.com/glossary/)
|
|
119
|
+
|
|
120
|
+
### Resize & Transform
|
|
121
|
+
|
|
122
|
+
Video resizing scales frames to target dimensions while maintaining aspect ratio. Rotation applies transpose filters for 90/180/270 degree rotations, handling both the video stream and any embedded rotation metadata.
|
|
123
|
+
|
|
124
|
+
| Resolution | Dimensions | Common Name |
|
|
125
|
+
|-----------|------------|-------------|
|
|
126
|
+
| 4K UHD | 3840 x 2160 | Ultra HD |
|
|
127
|
+
| 1080p | 1920 x 1080 | Full HD |
|
|
128
|
+
| 720p | 1280 x 720 | HD |
|
|
129
|
+
| 480p | 854 x 480 | SD |
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { resize, rotate } from "peasy-video-js";
|
|
133
|
+
|
|
134
|
+
// Resize to 720p (maintains aspect ratio)
|
|
135
|
+
const hd = await resize("4k-video.mp4", { width: 1280, height: 720 });
|
|
136
|
+
|
|
137
|
+
// Resize by width only (auto-calculates height)
|
|
138
|
+
const small = await resize("video.mp4", { width: 640 });
|
|
139
|
+
|
|
140
|
+
// Rotate 90 degrees clockwise
|
|
141
|
+
const rotated = await rotate("portrait.mp4", { degrees: 90 });
|
|
142
|
+
|
|
143
|
+
// Rotate 180 degrees (flip upside down)
|
|
144
|
+
const flipped = await rotate("upside-down.mp4", { degrees: 180 });
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Learn more: [Peasy Video Tools](https://peasyvideo.com) · [Glossary](https://peasytools.com/glossary/)
|
|
148
|
+
|
|
149
|
+
### Audio Extraction
|
|
150
|
+
|
|
151
|
+
Extract the audio track from a video file as a standalone audio file, or strip the audio track entirely to produce a silent video. Audio extraction preserves the original codec when possible, avoiding re-encoding for faster processing.
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { extractAudio, stripAudio } from "peasy-video-js";
|
|
155
|
+
|
|
156
|
+
// Extract audio as MP3
|
|
157
|
+
const audio = await extractAudio("interview.mp4", "mp3");
|
|
158
|
+
|
|
159
|
+
// Extract audio as WAV (lossless)
|
|
160
|
+
const wav = await extractAudio("concert.mp4", "wav");
|
|
161
|
+
|
|
162
|
+
// Remove audio track from video (silent output)
|
|
163
|
+
const silent = await stripAudio("presentation.mp4");
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Learn more: [Peasy Video Tools](https://peasyvideo.com) · [Glossary](https://peasytools.com/glossary/)
|
|
167
|
+
|
|
168
|
+
### Thumbnails
|
|
169
|
+
|
|
170
|
+
Extract individual frames as images for preview thumbnails, video galleries, or content analysis. Single-frame extraction uses precise seeking, while multi-frame extraction distributes captures evenly across the video duration.
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import { thumbnail, thumbnails } from "peasy-video-js";
|
|
174
|
+
|
|
175
|
+
// Extract a frame at 5 seconds as PNG
|
|
176
|
+
const frame = await thumbnail("video.mp4", { time: 5 });
|
|
177
|
+
|
|
178
|
+
// Extract a frame with custom dimensions
|
|
179
|
+
const small = await thumbnail("video.mp4", {
|
|
180
|
+
time: 10,
|
|
181
|
+
width: 320,
|
|
182
|
+
height: 180,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Generate 10 evenly-spaced thumbnails
|
|
186
|
+
const frames = await thumbnails("video.mp4", 10, { width: 320 });
|
|
187
|
+
// Returns array of paths: ["/tmp/peasy-video-xxx-0.png", ...]
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Learn more: [Peasy Video Tools](https://peasyvideo.com) · [Glossary](https://peasytools.com/glossary/)
|
|
191
|
+
|
|
192
|
+
### GIF Conversion
|
|
193
|
+
|
|
194
|
+
Convert video clips to animated GIFs with palette optimization for smaller file sizes and better color reproduction. Convert GIFs back to MP4 for efficient playback and embedding.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
import { videoToGif, gifToVideo } from "peasy-video-js";
|
|
198
|
+
|
|
199
|
+
// Convert video to GIF with custom settings
|
|
200
|
+
const gif = await videoToGif("clip.mp4", {
|
|
201
|
+
fps: 15, // frames per second
|
|
202
|
+
width: 480, // pixel width
|
|
203
|
+
start: 2, // start at 2 seconds
|
|
204
|
+
duration: 5, // 5-second GIF
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Convert GIF back to MP4 (much smaller file size)
|
|
208
|
+
const mp4 = await gifToVideo("animation.gif");
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Learn more: [Peasy Video Tools](https://peasyvideo.com) · [Glossary](https://peasytools.com/glossary/)
|
|
212
|
+
|
|
213
|
+
### Speed & Reverse
|
|
214
|
+
|
|
215
|
+
Adjust playback speed using FFmpeg's `setpts` (video) and `atempo` (audio) filters. Speed factors below 1.0 create slow motion, above 1.0 create fast motion. Reverse plays the video backwards, re-encoding all frames.
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
import { speed, reverseVideo } from "peasy-video-js";
|
|
219
|
+
|
|
220
|
+
// Double the playback speed
|
|
221
|
+
const fast = await speed("lecture.mp4", { factor: 2.0 });
|
|
222
|
+
|
|
223
|
+
// Slow motion at half speed
|
|
224
|
+
const slow = await speed("action.mp4", { factor: 0.5 });
|
|
225
|
+
|
|
226
|
+
// Reverse the entire video
|
|
227
|
+
const reversed = await reverseVideo("clip.mp4");
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Learn more: [Peasy Video Tools](https://peasyvideo.com) · [Glossary](https://peasytools.com/glossary/)
|
|
231
|
+
|
|
232
|
+
## TypeScript Types
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
import type {
|
|
236
|
+
VideoFormat,
|
|
237
|
+
VideoInfo,
|
|
238
|
+
TrimOptions,
|
|
239
|
+
ResizeOptions,
|
|
240
|
+
RotateOptions,
|
|
241
|
+
ThumbnailOptions,
|
|
242
|
+
GifOptions,
|
|
243
|
+
SpeedOptions,
|
|
244
|
+
ThumbnailResult,
|
|
245
|
+
} from "peasy-video-js";
|
|
246
|
+
|
|
247
|
+
// VideoFormat — "mp4" | "webm" | "mkv" | "avi" | "mov" | "gif"
|
|
248
|
+
const format: VideoFormat = "mp4";
|
|
249
|
+
|
|
250
|
+
// VideoInfo — metadata from info()
|
|
251
|
+
const meta: VideoInfo = {
|
|
252
|
+
duration: 120.5,
|
|
253
|
+
width: 1920,
|
|
254
|
+
height: 1080,
|
|
255
|
+
fps: 30,
|
|
256
|
+
codec: "h264",
|
|
257
|
+
format: "mov,mp4,m4a,3gp,3g2,mj2",
|
|
258
|
+
bitrate: 5_000_000,
|
|
259
|
+
size: 75_000_000,
|
|
260
|
+
hasAudio: true,
|
|
261
|
+
};
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## API Reference
|
|
265
|
+
|
|
266
|
+
| Function | Description |
|
|
267
|
+
|----------|-------------|
|
|
268
|
+
| `info(input)` | Get video metadata (resolution, fps, codec, duration, bitrate) |
|
|
269
|
+
| `trim(input, options)` | Extract a segment by start/end/duration |
|
|
270
|
+
| `resize(input, options)` | Scale video to target dimensions |
|
|
271
|
+
| `rotate(input, options)` | Rotate video 90/180/270 degrees |
|
|
272
|
+
| `concatenate(inputs)` | Join multiple video files sequentially |
|
|
273
|
+
| `extractAudio(input, format?)` | Extract audio track as standalone file |
|
|
274
|
+
| `stripAudio(input)` | Remove audio track from video |
|
|
275
|
+
| `thumbnail(input, options?)` | Extract a single frame as PNG |
|
|
276
|
+
| `thumbnails(input, count, options?)` | Extract multiple evenly-spaced frames |
|
|
277
|
+
| `videoToGif(input, options?)` | Convert video clip to animated GIF |
|
|
278
|
+
| `gifToVideo(input)` | Convert GIF to MP4 |
|
|
279
|
+
| `reverseVideo(input)` | Play video in reverse |
|
|
280
|
+
| `speed(input, options)` | Adjust playback speed (0.5x to 4.0x) |
|
|
281
|
+
|
|
282
|
+
## Also Available for Python
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
pip install peasy-video
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The Python package provides the same 13 video operations with CLI and moviepy engine. See [peasy-video on PyPI](https://pypi.org/project/peasy-video/).
|
|
289
|
+
|
|
290
|
+
## Peasy Developer Tools
|
|
291
|
+
|
|
292
|
+
| Package | PyPI | npm | Description |
|
|
293
|
+
|---------|------|-----|-------------|
|
|
294
|
+
| peasy-pdf | [PyPI](https://pypi.org/project/peasy-pdf/) | [npm](https://www.npmjs.com/package/peasy-pdf-js) | PDF merge, split, compress, rotate, watermark |
|
|
295
|
+
| peasy-image | [PyPI](https://pypi.org/project/peasy-image/) | [npm](https://www.npmjs.com/package/peasy-image-js) | Image resize, crop, compress, convert, watermark |
|
|
296
|
+
| peasytext | [PyPI](https://pypi.org/project/peasytext/) | [npm](https://www.npmjs.com/package/peasytext-js) | Text analysis, case conversion, slugs, word count |
|
|
297
|
+
| peasy-css | [PyPI](https://pypi.org/project/peasy-css/) | [npm](https://www.npmjs.com/package/peasy-css-js) | CSS gradients, shadows, flexbox, grid generators |
|
|
298
|
+
| peasy-compress | [PyPI](https://pypi.org/project/peasy-compress/) | [npm](https://www.npmjs.com/package/peasy-compress-js) | ZIP, gzip, brotli, deflate compression |
|
|
299
|
+
| peasy-document | [PyPI](https://pypi.org/project/peasy-document/) | [npm](https://www.npmjs.com/package/peasy-document-js) | Markdown, HTML, CSV, JSON, YAML conversion |
|
|
300
|
+
| peasy-audio | [PyPI](https://pypi.org/project/peasy-audio/) | [npm](https://www.npmjs.com/package/peasy-audio-js) | Audio convert, trim, merge, normalize, effects |
|
|
301
|
+
| **peasy-video** | [PyPI](https://pypi.org/project/peasy-video/) | **[npm](https://www.npmjs.com/package/peasy-video-js)** | **Video trim, resize, thumbnails, GIF conversion** |
|
|
302
|
+
|
|
303
|
+
Part of the [Peasy](https://peasytools.com) developer tools ecosystem.
|
|
304
|
+
|
|
305
|
+
## License
|
|
306
|
+
|
|
307
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peasy-video-js — Type definitions for video processing.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
/** Supported video container formats. */
|
|
7
|
+
type VideoFormat = "mp4" | "webm" | "mkv" | "avi" | "mov" | "gif";
|
|
8
|
+
/** Video file metadata. */
|
|
9
|
+
interface VideoInfo {
|
|
10
|
+
duration: number;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
fps: number;
|
|
14
|
+
codec: string;
|
|
15
|
+
format: string;
|
|
16
|
+
bitrate: number;
|
|
17
|
+
size: number;
|
|
18
|
+
hasAudio: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Options for video trimming. */
|
|
21
|
+
interface TrimOptions {
|
|
22
|
+
start?: number;
|
|
23
|
+
end?: number;
|
|
24
|
+
duration?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Options for video resizing. */
|
|
27
|
+
interface ResizeOptions {
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
fit?: "contain" | "cover" | "fill";
|
|
31
|
+
}
|
|
32
|
+
/** Options for video rotation. */
|
|
33
|
+
interface RotateOptions {
|
|
34
|
+
degrees: 90 | 180 | 270;
|
|
35
|
+
}
|
|
36
|
+
/** Options for thumbnail extraction. */
|
|
37
|
+
interface ThumbnailOptions {
|
|
38
|
+
time?: number;
|
|
39
|
+
width?: number;
|
|
40
|
+
height?: number;
|
|
41
|
+
}
|
|
42
|
+
/** Options for GIF conversion. */
|
|
43
|
+
interface GifOptions {
|
|
44
|
+
fps?: number;
|
|
45
|
+
width?: number;
|
|
46
|
+
start?: number;
|
|
47
|
+
duration?: number;
|
|
48
|
+
}
|
|
49
|
+
/** Options for speed adjustment. */
|
|
50
|
+
interface SpeedOptions {
|
|
51
|
+
factor: number;
|
|
52
|
+
}
|
|
53
|
+
/** Thumbnail result. */
|
|
54
|
+
interface ThumbnailResult {
|
|
55
|
+
path: string;
|
|
56
|
+
time: number;
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* peasy-video-js — Video processing engine powered by FFmpeg.
|
|
63
|
+
*
|
|
64
|
+
* 13 functions: info, trim, resize, rotate, concatenate, extractAudio,
|
|
65
|
+
* stripAudio, thumbnail, thumbnails, videoToGif, gifToVideo, reverseVideo, speed.
|
|
66
|
+
* All output functions return the path to the generated file.
|
|
67
|
+
*
|
|
68
|
+
* @packageDocumentation
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get metadata for a video file using ffprobe.
|
|
73
|
+
*
|
|
74
|
+
* @param input - Path to the video file
|
|
75
|
+
* @returns Video metadata including duration, dimensions, fps, codec, and more
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```typescript
|
|
79
|
+
* const metadata = await info("sample.mp4");
|
|
80
|
+
* console.log(metadata.duration); // 120.5
|
|
81
|
+
* console.log(metadata.width); // 1920
|
|
82
|
+
* console.log(metadata.height); // 1080
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare function info(input: string): Promise<VideoInfo>;
|
|
86
|
+
/**
|
|
87
|
+
* Trim a video to a specific time range.
|
|
88
|
+
*
|
|
89
|
+
* @param input - Path to the input video
|
|
90
|
+
* @param options - Trim options: start, end, and/or duration in seconds
|
|
91
|
+
* @returns Path to the trimmed output file
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* // Trim from 10s to 30s
|
|
96
|
+
* const trimmed = await trim("input.mp4", { start: 10, end: 30 });
|
|
97
|
+
*
|
|
98
|
+
* // Trim first 5 seconds
|
|
99
|
+
* const first5 = await trim("input.mp4", { duration: 5 });
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
declare function trim(input: string, options: TrimOptions): Promise<string>;
|
|
103
|
+
/**
|
|
104
|
+
* Resize a video to the specified dimensions.
|
|
105
|
+
*
|
|
106
|
+
* @param input - Path to the input video
|
|
107
|
+
* @param options - Resize options: width, height, and fit mode
|
|
108
|
+
* @returns Path to the resized output file
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```typescript
|
|
112
|
+
* // Resize to 1280x720
|
|
113
|
+
* const resized = await resize("input.mp4", { width: 1280, height: 720 });
|
|
114
|
+
*
|
|
115
|
+
* // Scale width to 640, maintain aspect ratio
|
|
116
|
+
* const scaled = await resize("input.mp4", { width: 640 });
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
declare function resize(input: string, options: ResizeOptions): Promise<string>;
|
|
120
|
+
/**
|
|
121
|
+
* Rotate a video by 90, 180, or 270 degrees.
|
|
122
|
+
*
|
|
123
|
+
* @param input - Path to the input video
|
|
124
|
+
* @param options - Rotation options: degrees (90, 180, or 270)
|
|
125
|
+
* @returns Path to the rotated output file
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```typescript
|
|
129
|
+
* const rotated = await rotate("input.mp4", { degrees: 90 });
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
declare function rotate(input: string, options: RotateOptions): Promise<string>;
|
|
133
|
+
/**
|
|
134
|
+
* Concatenate multiple video files into one.
|
|
135
|
+
*
|
|
136
|
+
* @param inputs - Array of paths to video files (in order)
|
|
137
|
+
* @returns Path to the concatenated output file
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```typescript
|
|
141
|
+
* const joined = await concatenate(["part1.mp4", "part2.mp4", "part3.mp4"]);
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
declare function concatenate(inputs: string[]): Promise<string>;
|
|
145
|
+
/**
|
|
146
|
+
* Extract the audio track from a video file.
|
|
147
|
+
*
|
|
148
|
+
* @param input - Path to the input video
|
|
149
|
+
* @param format - Audio output format (default: "mp3")
|
|
150
|
+
* @returns Path to the extracted audio file
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```typescript
|
|
154
|
+
* const audio = await extractAudio("video.mp4"); // MP3
|
|
155
|
+
* const wav = await extractAudio("video.mp4", "wav"); // WAV
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
declare function extractAudio(input: string, format?: string): Promise<string>;
|
|
159
|
+
/**
|
|
160
|
+
* Remove the audio track from a video, producing a silent video.
|
|
161
|
+
*
|
|
162
|
+
* @param input - Path to the input video
|
|
163
|
+
* @returns Path to the silent output video
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```typescript
|
|
167
|
+
* const silent = await stripAudio("video-with-music.mp4");
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
declare function stripAudio(input: string): Promise<string>;
|
|
171
|
+
/**
|
|
172
|
+
* Extract a single thumbnail frame from a video.
|
|
173
|
+
*
|
|
174
|
+
* @param input - Path to the input video
|
|
175
|
+
* @param options - Thumbnail options: time (seconds), width, height
|
|
176
|
+
* @returns Path to the generated thumbnail image (PNG)
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* // Thumbnail at 5 seconds
|
|
181
|
+
* const thumb = await thumbnail("video.mp4", { time: 5 });
|
|
182
|
+
*
|
|
183
|
+
* // Thumbnail at start with custom size
|
|
184
|
+
* const small = await thumbnail("video.mp4", { width: 320, height: 180 });
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
declare function thumbnail(input: string, options?: ThumbnailOptions): Promise<string>;
|
|
188
|
+
/**
|
|
189
|
+
* Extract multiple evenly-spaced thumbnails from a video.
|
|
190
|
+
*
|
|
191
|
+
* @param input - Path to the input video
|
|
192
|
+
* @param count - Number of thumbnails to extract
|
|
193
|
+
* @param options - Optional width and height for the thumbnails
|
|
194
|
+
* @returns Array of paths to the generated thumbnail images (PNG)
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```typescript
|
|
198
|
+
* // Extract 5 thumbnails spread across the video
|
|
199
|
+
* const thumbs = await thumbnails("video.mp4", 5);
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
declare function thumbnails(input: string, count: number, options?: Pick<ThumbnailOptions, "width" | "height">): Promise<string[]>;
|
|
203
|
+
/**
|
|
204
|
+
* Convert a video to an animated GIF.
|
|
205
|
+
*
|
|
206
|
+
* Uses a two-pass palette generation for high-quality GIF output.
|
|
207
|
+
*
|
|
208
|
+
* @param input - Path to the input video
|
|
209
|
+
* @param options - GIF options: fps, width, start time, duration
|
|
210
|
+
* @returns Path to the generated GIF file
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* ```typescript
|
|
214
|
+
* // Convert full video to GIF at 10fps
|
|
215
|
+
* const gif = await videoToGif("input.mp4", { fps: 10, width: 480 });
|
|
216
|
+
*
|
|
217
|
+
* // Convert a 3-second segment starting at 5s
|
|
218
|
+
* const clip = await videoToGif("input.mp4", { start: 5, duration: 3 });
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
declare function videoToGif(input: string, options?: GifOptions): Promise<string>;
|
|
222
|
+
/**
|
|
223
|
+
* Convert a GIF to a video (MP4).
|
|
224
|
+
*
|
|
225
|
+
* @param input - Path to the input GIF file
|
|
226
|
+
* @returns Path to the generated MP4 video file
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```typescript
|
|
230
|
+
* const video = await gifToVideo("animation.gif");
|
|
231
|
+
* ```
|
|
232
|
+
*/
|
|
233
|
+
declare function gifToVideo(input: string): Promise<string>;
|
|
234
|
+
/**
|
|
235
|
+
* Reverse a video so it plays backwards.
|
|
236
|
+
*
|
|
237
|
+
* @param input - Path to the input video
|
|
238
|
+
* @returns Path to the reversed output video
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```typescript
|
|
242
|
+
* const reversed = await reverseVideo("input.mp4");
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
declare function reverseVideo(input: string): Promise<string>;
|
|
246
|
+
/**
|
|
247
|
+
* Change the playback speed of a video.
|
|
248
|
+
*
|
|
249
|
+
* @param input - Path to the input video
|
|
250
|
+
* @param options - Speed options: factor (>1 = faster, <1 = slower)
|
|
251
|
+
* @returns Path to the speed-adjusted output video
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```typescript
|
|
255
|
+
* // 2x speed
|
|
256
|
+
* const fast = await speed("input.mp4", { factor: 2 });
|
|
257
|
+
*
|
|
258
|
+
* // Half speed (slow motion)
|
|
259
|
+
* const slow = await speed("input.mp4", { factor: 0.5 });
|
|
260
|
+
* ```
|
|
261
|
+
*/
|
|
262
|
+
declare function speed(input: string, options: SpeedOptions): Promise<string>;
|
|
263
|
+
|
|
264
|
+
export { type GifOptions, type ResizeOptions, type RotateOptions, type SpeedOptions, type ThumbnailOptions, type ThumbnailResult, type TrimOptions, type VideoFormat, type VideoInfo, concatenate, extractAudio, gifToVideo, info, resize, reverseVideo, rotate, speed, stripAudio, thumbnail, thumbnails, trim, videoToGif };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// src/engine.ts
|
|
2
|
+
import ffmpeg from "fluent-ffmpeg";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join, extname } from "path";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import { writeFileSync } from "fs";
|
|
7
|
+
function tmpOutput(ext) {
|
|
8
|
+
return join(tmpdir(), `peasy-video-${randomUUID()}.${ext}`);
|
|
9
|
+
}
|
|
10
|
+
function parseFps(rate) {
|
|
11
|
+
if (!rate) return 0;
|
|
12
|
+
const parts = rate.split("/");
|
|
13
|
+
if (parts.length === 2) {
|
|
14
|
+
const num = Number(parts[0]);
|
|
15
|
+
const den = Number(parts[1]);
|
|
16
|
+
return den > 0 ? num / den : 0;
|
|
17
|
+
}
|
|
18
|
+
return Number(rate) || 0;
|
|
19
|
+
}
|
|
20
|
+
function run(command, outputPath) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
command.output(outputPath).on("end", () => resolve(outputPath)).on("error", (err) => reject(err)).run();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function getExt(filePath) {
|
|
26
|
+
const ext = extname(filePath).slice(1).toLowerCase();
|
|
27
|
+
return ext || "mp4";
|
|
28
|
+
}
|
|
29
|
+
function info(input) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
ffmpeg.ffprobe(input, (err, metadata) => {
|
|
32
|
+
if (err) return reject(err);
|
|
33
|
+
const video = metadata.streams.find((s) => s.codec_type === "video");
|
|
34
|
+
const audio = metadata.streams.find((s) => s.codec_type === "audio");
|
|
35
|
+
resolve({
|
|
36
|
+
duration: metadata.format.duration ?? 0,
|
|
37
|
+
width: video?.width ?? 0,
|
|
38
|
+
height: video?.height ?? 0,
|
|
39
|
+
fps: parseFps(video?.r_frame_rate),
|
|
40
|
+
codec: video?.codec_name ?? "unknown",
|
|
41
|
+
format: metadata.format.format_name ?? "unknown",
|
|
42
|
+
bitrate: metadata.format.bit_rate ? Number(metadata.format.bit_rate) : 0,
|
|
43
|
+
size: metadata.format.size ? Number(metadata.format.size) : 0,
|
|
44
|
+
hasAudio: !!audio
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function trim(input, options) {
|
|
50
|
+
const output = tmpOutput(getExt(input));
|
|
51
|
+
const cmd = ffmpeg(input);
|
|
52
|
+
if (options.start !== void 0) {
|
|
53
|
+
cmd.setStartTime(options.start);
|
|
54
|
+
}
|
|
55
|
+
if (options.end !== void 0 && options.start !== void 0) {
|
|
56
|
+
cmd.setDuration(options.end - options.start);
|
|
57
|
+
} else if (options.duration !== void 0) {
|
|
58
|
+
cmd.setDuration(options.duration);
|
|
59
|
+
}
|
|
60
|
+
cmd.outputOptions("-c", "copy");
|
|
61
|
+
return run(cmd, output);
|
|
62
|
+
}
|
|
63
|
+
function resize(input, options) {
|
|
64
|
+
const output = tmpOutput(getExt(input));
|
|
65
|
+
const cmd = ffmpeg(input);
|
|
66
|
+
let scaleFilter;
|
|
67
|
+
if (options.width && options.height) {
|
|
68
|
+
if (options.fit === "cover") {
|
|
69
|
+
scaleFilter = `scale=${options.width}:${options.height}:force_original_aspect_ratio=increase,crop=${options.width}:${options.height}`;
|
|
70
|
+
} else if (options.fit === "contain") {
|
|
71
|
+
scaleFilter = `scale=${options.width}:${options.height}:force_original_aspect_ratio=decrease,pad=${options.width}:${options.height}:(ow-iw)/2:(oh-ih)/2`;
|
|
72
|
+
} else {
|
|
73
|
+
scaleFilter = `scale=${options.width}:${options.height}`;
|
|
74
|
+
}
|
|
75
|
+
} else if (options.width) {
|
|
76
|
+
scaleFilter = `scale=${options.width}:-2`;
|
|
77
|
+
} else if (options.height) {
|
|
78
|
+
scaleFilter = `scale=-2:${options.height}`;
|
|
79
|
+
} else {
|
|
80
|
+
return run(cmd, output);
|
|
81
|
+
}
|
|
82
|
+
cmd.videoFilter(scaleFilter);
|
|
83
|
+
return run(cmd, output);
|
|
84
|
+
}
|
|
85
|
+
function rotate(input, options) {
|
|
86
|
+
const output = tmpOutput(getExt(input));
|
|
87
|
+
const cmd = ffmpeg(input);
|
|
88
|
+
switch (options.degrees) {
|
|
89
|
+
case 90:
|
|
90
|
+
cmd.videoFilter("transpose=1");
|
|
91
|
+
break;
|
|
92
|
+
case 180:
|
|
93
|
+
cmd.videoFilter("transpose=1,transpose=1");
|
|
94
|
+
break;
|
|
95
|
+
case 270:
|
|
96
|
+
cmd.videoFilter("transpose=2");
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
return run(cmd, output);
|
|
100
|
+
}
|
|
101
|
+
function concatenate(inputs) {
|
|
102
|
+
if (inputs.length < 2) {
|
|
103
|
+
return Promise.reject(new Error("At least 2 inputs are required for concatenation"));
|
|
104
|
+
}
|
|
105
|
+
const output = tmpOutput(getExt(inputs[0]));
|
|
106
|
+
const listPath = tmpOutput("txt");
|
|
107
|
+
const listContent = inputs.map((f) => `file '${f}'`).join("\n");
|
|
108
|
+
writeFileSync(listPath, listContent, "utf-8");
|
|
109
|
+
const cmd = ffmpeg().input(listPath).inputOptions("-f", "concat", "-safe", "0").outputOptions("-c", "copy");
|
|
110
|
+
return run(cmd, output);
|
|
111
|
+
}
|
|
112
|
+
function extractAudio(input, format = "mp3") {
|
|
113
|
+
const output = tmpOutput(format);
|
|
114
|
+
const cmd = ffmpeg(input).noVideo();
|
|
115
|
+
return run(cmd, output);
|
|
116
|
+
}
|
|
117
|
+
function stripAudio(input) {
|
|
118
|
+
const output = tmpOutput(getExt(input));
|
|
119
|
+
const cmd = ffmpeg(input).noAudio().outputOptions("-c:v", "copy");
|
|
120
|
+
return run(cmd, output);
|
|
121
|
+
}
|
|
122
|
+
function thumbnail(input, options = {}) {
|
|
123
|
+
const output = tmpOutput("png");
|
|
124
|
+
const cmd = ffmpeg(input);
|
|
125
|
+
if (options.time !== void 0) {
|
|
126
|
+
cmd.seekInput(options.time);
|
|
127
|
+
}
|
|
128
|
+
cmd.frames(1);
|
|
129
|
+
if (options.width || options.height) {
|
|
130
|
+
const w = options.width ?? -2;
|
|
131
|
+
const h = options.height ?? -2;
|
|
132
|
+
cmd.videoFilter(`scale=${w}:${h}`);
|
|
133
|
+
}
|
|
134
|
+
return run(cmd, output);
|
|
135
|
+
}
|
|
136
|
+
async function thumbnails(input, count, options = {}) {
|
|
137
|
+
const meta = await info(input);
|
|
138
|
+
const duration = meta.duration;
|
|
139
|
+
const results = [];
|
|
140
|
+
for (let i = 0; i < count; i++) {
|
|
141
|
+
const time = count > 1 ? i / (count - 1) * duration : 0;
|
|
142
|
+
const clampedTime = Math.min(time, Math.max(0, duration - 0.01));
|
|
143
|
+
const thumbPath = await thumbnail(input, {
|
|
144
|
+
time: clampedTime,
|
|
145
|
+
...options
|
|
146
|
+
});
|
|
147
|
+
results.push(thumbPath);
|
|
148
|
+
}
|
|
149
|
+
return results;
|
|
150
|
+
}
|
|
151
|
+
function videoToGif(input, options = {}) {
|
|
152
|
+
const output = tmpOutput("gif");
|
|
153
|
+
const cmd = ffmpeg(input);
|
|
154
|
+
if (options.start !== void 0) {
|
|
155
|
+
cmd.setStartTime(options.start);
|
|
156
|
+
}
|
|
157
|
+
if (options.duration !== void 0) {
|
|
158
|
+
cmd.setDuration(options.duration);
|
|
159
|
+
}
|
|
160
|
+
const filters = [];
|
|
161
|
+
if (options.fps) {
|
|
162
|
+
filters.push(`fps=${options.fps}`);
|
|
163
|
+
}
|
|
164
|
+
if (options.width) {
|
|
165
|
+
filters.push(`scale=${options.width}:-1:flags=lanczos`);
|
|
166
|
+
}
|
|
167
|
+
if (filters.length > 0) {
|
|
168
|
+
cmd.videoFilter(filters.join(","));
|
|
169
|
+
}
|
|
170
|
+
return run(cmd, output);
|
|
171
|
+
}
|
|
172
|
+
function gifToVideo(input) {
|
|
173
|
+
const output = tmpOutput("mp4");
|
|
174
|
+
const cmd = ffmpeg(input).outputOptions("-movflags", "+faststart").outputOptions("-pix_fmt", "yuv420p");
|
|
175
|
+
return run(cmd, output);
|
|
176
|
+
}
|
|
177
|
+
function reverseVideo(input) {
|
|
178
|
+
const output = tmpOutput(getExt(input));
|
|
179
|
+
const cmd = ffmpeg(input).videoFilter("reverse").audioFilter("areverse");
|
|
180
|
+
return run(cmd, output);
|
|
181
|
+
}
|
|
182
|
+
function speed(input, options) {
|
|
183
|
+
if (options.factor <= 0) {
|
|
184
|
+
return Promise.reject(new Error("Speed factor must be positive"));
|
|
185
|
+
}
|
|
186
|
+
const output = tmpOutput(getExt(input));
|
|
187
|
+
const cmd = ffmpeg(input);
|
|
188
|
+
const ptsFactor = 1 / options.factor;
|
|
189
|
+
cmd.videoFilter(`setpts=${ptsFactor}*PTS`);
|
|
190
|
+
const atempoFilters = [];
|
|
191
|
+
let remaining = options.factor;
|
|
192
|
+
while (remaining > 100) {
|
|
193
|
+
atempoFilters.push("atempo=100.0");
|
|
194
|
+
remaining /= 100;
|
|
195
|
+
}
|
|
196
|
+
while (remaining < 0.5) {
|
|
197
|
+
atempoFilters.push("atempo=0.5");
|
|
198
|
+
remaining /= 0.5;
|
|
199
|
+
}
|
|
200
|
+
atempoFilters.push(`atempo=${remaining}`);
|
|
201
|
+
cmd.audioFilter(atempoFilters.join(","));
|
|
202
|
+
return run(cmd, output);
|
|
203
|
+
}
|
|
204
|
+
export {
|
|
205
|
+
concatenate,
|
|
206
|
+
extractAudio,
|
|
207
|
+
gifToVideo,
|
|
208
|
+
info,
|
|
209
|
+
resize,
|
|
210
|
+
reverseVideo,
|
|
211
|
+
rotate,
|
|
212
|
+
speed,
|
|
213
|
+
stripAudio,
|
|
214
|
+
thumbnail,
|
|
215
|
+
thumbnails,
|
|
216
|
+
trim,
|
|
217
|
+
videoToGif
|
|
218
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "peasy-video",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Video processing library for Node.js \u2014 trim, resize, rotate, thumbnails, GIF conversion. FFmpeg-powered, TypeScript-first.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"video",
|
|
24
|
+
"mp4",
|
|
25
|
+
"ffmpeg",
|
|
26
|
+
"trim",
|
|
27
|
+
"resize",
|
|
28
|
+
"thumbnail",
|
|
29
|
+
"gif",
|
|
30
|
+
"peasy"
|
|
31
|
+
],
|
|
32
|
+
"author": "Peasy Tools",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"url": "https://github.com/peasytools/peasy-video-js.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://peasyvideo.com",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"fluent-ffmpeg": "^2.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/fluent-ffmpeg": "^2.1",
|
|
43
|
+
"tsup": "^8.0",
|
|
44
|
+
"typescript": "^5.7",
|
|
45
|
+
"vitest": "^3.0"
|
|
46
|
+
}
|
|
47
|
+
}
|