vidpeek 0.1.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/License +21 -0
- package/README.md +123 -0
- package/dist/chunk-KVP6XIDZ.js +457 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +88 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +8 -0
- package/package.json +58 -0
package/License
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Piero Alessandro Postigo Rocchetti
|
|
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,123 @@
|
|
|
1
|
+
# VidPeek
|
|
2
|
+
|
|
3
|
+
Generate previews. Not FFmpeg headaches.
|
|
4
|
+
|
|
5
|
+
Modern typed video preview generation for Node and CLI. VidPeek turns videos into lightweight animated previews using FFmpeg, with clean presets, safe temp handling, typed options, and a CLI designed for media apps.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add vidpeek
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install vidpeek
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## FFmpeg Requirement
|
|
18
|
+
|
|
19
|
+
VidPeek uses FFmpeg and FFprobe under the hood. Install both and make sure `ffmpeg` and `ffprobe` are available on your `PATH`, or pass custom binary paths with `--ffmpeg-path`, `--ffprobe-path`, `ffmpegPath`, and `ffprobePath`.
|
|
20
|
+
|
|
21
|
+
## CLI Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
vidpeek input.mp4 --out preview.webp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
vidpeek input.mp4 \
|
|
29
|
+
--out preview.webp \
|
|
30
|
+
--preset web \
|
|
31
|
+
--strategy evenly-spaced \
|
|
32
|
+
--clips 5 \
|
|
33
|
+
--clip-duration 2 \
|
|
34
|
+
--width 320 \
|
|
35
|
+
--fps 10
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Print JSON for application workflows:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
vidpeek input.mp4 --out preview.webp --json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Node API
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { generatePreview } from "vidpeek";
|
|
48
|
+
|
|
49
|
+
await generatePreview({
|
|
50
|
+
input: "video.mp4",
|
|
51
|
+
output: "preview.webp",
|
|
52
|
+
preset: "web",
|
|
53
|
+
strategy: "evenly-spaced",
|
|
54
|
+
clips: {
|
|
55
|
+
count: 5,
|
|
56
|
+
duration: 2,
|
|
57
|
+
},
|
|
58
|
+
width: 320,
|
|
59
|
+
fps: 10,
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Presets
|
|
64
|
+
|
|
65
|
+
| Preset | Format | Width | FPS | Clips | Clip duration |
|
|
66
|
+
| --- | --- | ---: | ---: | ---: | ---: |
|
|
67
|
+
| `tiny` | `webp` | 240 | 8 | 4 | 1.5s |
|
|
68
|
+
| `web` | `webp` | 320 | 10 | 5 | 2s |
|
|
69
|
+
| `discord` | `webp` | 360 | 12 | 4 | 2s |
|
|
70
|
+
| `high-quality` | `webp` | 480 | 15 | 6 | 2s |
|
|
71
|
+
|
|
72
|
+
## Options
|
|
73
|
+
|
|
74
|
+
| Option | Type | Description |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `input` | `string` | Input video path. |
|
|
77
|
+
| `output` | `string` | Output preview path. |
|
|
78
|
+
| `format` | `"webp" \| "gif" \| "mp4"` | Output format. Defaults from the selected preset. |
|
|
79
|
+
| `preset` | `"tiny" \| "web" \| "discord" \| "high-quality"` | Preview preset. Defaults to `web`. |
|
|
80
|
+
| `strategy` | `"evenly-spaced" \| "random" \| "manual"` | Segment selection strategy. Defaults to `evenly-spaced`. |
|
|
81
|
+
| `clips.count` | `number` | Number of clips to sample. |
|
|
82
|
+
| `clips.duration` | `number` | Duration of each sampled clip in seconds. |
|
|
83
|
+
| `clips.range` | `[number, number]` | Normalized sampling range. Defaults to `[0.05, 0.95]`. |
|
|
84
|
+
| `clips.segments` | `{ start: number; duration: number }[]` | Required for manual segment selection. |
|
|
85
|
+
| `width` | `number` | Output width. |
|
|
86
|
+
| `height` | `number` | Output height. |
|
|
87
|
+
| `fps` | `number` | Output frames per second. |
|
|
88
|
+
| `speed` | `number` | Preview speed multiplier. |
|
|
89
|
+
| `overwrite` | `boolean` | Replace an existing output file. |
|
|
90
|
+
| `keepTemp` | `boolean` | Keep the temporary work directory for debugging. |
|
|
91
|
+
| `ffmpegPath` | `string` | Custom FFmpeg binary path. |
|
|
92
|
+
| `ffprobePath` | `string` | Custom FFprobe binary path. |
|
|
93
|
+
|
|
94
|
+
## Benchmark
|
|
95
|
+
|
|
96
|
+
Place a sample video at `benchmarks/fixtures/sample.mp4`, then run:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pnpm bench
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The benchmark generates `tiny`, `web`, and `high-quality` previews and prints elapsed time plus output file size.
|
|
103
|
+
|
|
104
|
+
## Why VidPeek
|
|
105
|
+
|
|
106
|
+
- Typed API for Node apps.
|
|
107
|
+
- CLI and library from the same core pipeline.
|
|
108
|
+
- Safe per-run temp directories.
|
|
109
|
+
- No shell command strings.
|
|
110
|
+
- Readable FFmpeg and FFprobe errors.
|
|
111
|
+
- Useful presets for real media product workflows.
|
|
112
|
+
|
|
113
|
+
## Roadmap
|
|
114
|
+
|
|
115
|
+
- Scene-change strategy.
|
|
116
|
+
- Contact sheets.
|
|
117
|
+
- Sprites.
|
|
118
|
+
- AVIF previews.
|
|
119
|
+
- Worker and concurrency options.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// src/core/generate-preview.ts
|
|
2
|
+
import { access, mkdir, stat, writeFile } from "fs/promises";
|
|
3
|
+
import path2 from "path";
|
|
4
|
+
|
|
5
|
+
// src/core/errors.ts
|
|
6
|
+
var VidPeekError = class extends Error {
|
|
7
|
+
code;
|
|
8
|
+
command;
|
|
9
|
+
exitCode;
|
|
10
|
+
stderr;
|
|
11
|
+
constructor(message, options = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "VidPeekError";
|
|
14
|
+
this.code = options.code;
|
|
15
|
+
this.command = options.command;
|
|
16
|
+
this.exitCode = options.exitCode;
|
|
17
|
+
this.stderr = options.stderr;
|
|
18
|
+
if (options.cause) {
|
|
19
|
+
this.cause = options.cause;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function toVidPeekError(error) {
|
|
24
|
+
if (error instanceof VidPeekError) {
|
|
25
|
+
return error;
|
|
26
|
+
}
|
|
27
|
+
if (error instanceof Error) {
|
|
28
|
+
return new VidPeekError(error.message, { cause: error });
|
|
29
|
+
}
|
|
30
|
+
return new VidPeekError(String(error));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/core/ffmpeg.ts
|
|
34
|
+
import { spawn } from "child_process";
|
|
35
|
+
function quoteArg(arg) {
|
|
36
|
+
if (/^[a-zA-Z0-9_./:=+-]+$/.test(arg)) {
|
|
37
|
+
return arg;
|
|
38
|
+
}
|
|
39
|
+
return JSON.stringify(arg);
|
|
40
|
+
}
|
|
41
|
+
function formatCommand(binary, args) {
|
|
42
|
+
return [binary, ...args].map(quoteArg).join(" ");
|
|
43
|
+
}
|
|
44
|
+
async function runProcess({
|
|
45
|
+
binary,
|
|
46
|
+
args,
|
|
47
|
+
label = binary
|
|
48
|
+
}) {
|
|
49
|
+
const command = formatCommand(binary, args);
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const child = spawn(binary, args, {
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
54
|
+
});
|
|
55
|
+
let stdout = "";
|
|
56
|
+
let stderr = "";
|
|
57
|
+
child.stdout.setEncoding("utf8");
|
|
58
|
+
child.stderr.setEncoding("utf8");
|
|
59
|
+
child.stdout.on("data", (chunk) => {
|
|
60
|
+
stdout += chunk;
|
|
61
|
+
});
|
|
62
|
+
child.stderr.on("data", (chunk) => {
|
|
63
|
+
stderr += chunk;
|
|
64
|
+
});
|
|
65
|
+
child.on("error", (error) => {
|
|
66
|
+
reject(
|
|
67
|
+
new VidPeekError(
|
|
68
|
+
`${label} could not be started. Is it installed and available on PATH?`,
|
|
69
|
+
{
|
|
70
|
+
code: error.code ?? "PROCESS_START_FAILED",
|
|
71
|
+
command,
|
|
72
|
+
stderr,
|
|
73
|
+
cause: error
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
child.on("close", (exitCode) => {
|
|
79
|
+
if (exitCode === 0) {
|
|
80
|
+
resolve({ stdout, stderr });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const stderrText = stderr.trim();
|
|
84
|
+
reject(
|
|
85
|
+
new VidPeekError(
|
|
86
|
+
`${label} failed with exit code ${exitCode}.
|
|
87
|
+
Command: ${command}${stderrText ? `
|
|
88
|
+
|
|
89
|
+
${stderrText}` : ""}`,
|
|
90
|
+
{
|
|
91
|
+
code: "PROCESS_FAILED",
|
|
92
|
+
command,
|
|
93
|
+
exitCode,
|
|
94
|
+
stderr
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function runFfmpeg(args, ffmpegPath = "ffmpeg") {
|
|
102
|
+
return runProcess({ binary: ffmpegPath, args, label: "FFmpeg" });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/core/ffprobe.ts
|
|
106
|
+
function parseDuration(value) {
|
|
107
|
+
if (!value) {
|
|
108
|
+
return void 0;
|
|
109
|
+
}
|
|
110
|
+
const duration = Number(value);
|
|
111
|
+
return Number.isFinite(duration) && duration > 0 ? duration : void 0;
|
|
112
|
+
}
|
|
113
|
+
async function probeVideo(input, ffprobePath = "ffprobe") {
|
|
114
|
+
const { stdout } = await runProcess({
|
|
115
|
+
binary: ffprobePath,
|
|
116
|
+
label: "FFprobe",
|
|
117
|
+
args: ["-v", "error", "-show_format", "-show_streams", "-of", "json", input]
|
|
118
|
+
});
|
|
119
|
+
let data;
|
|
120
|
+
try {
|
|
121
|
+
data = JSON.parse(stdout);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new VidPeekError("FFprobe returned invalid JSON.", {
|
|
124
|
+
code: "INVALID_FFPROBE_JSON",
|
|
125
|
+
cause: error
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const duration = parseDuration(data.format?.duration) ?? parseDuration(data.streams?.find((stream) => stream.codec_type === "video")?.duration);
|
|
129
|
+
if (!duration) {
|
|
130
|
+
throw new VidPeekError("Could not read video duration from FFprobe output.", {
|
|
131
|
+
code: "MISSING_DURATION"
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return { duration };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/core/presets.ts
|
|
138
|
+
import { z } from "zod";
|
|
139
|
+
var PRESETS = {
|
|
140
|
+
tiny: {
|
|
141
|
+
format: "webp",
|
|
142
|
+
width: 240,
|
|
143
|
+
fps: 8,
|
|
144
|
+
clips: { count: 4, duration: 1.5 }
|
|
145
|
+
},
|
|
146
|
+
web: {
|
|
147
|
+
format: "webp",
|
|
148
|
+
width: 320,
|
|
149
|
+
fps: 10,
|
|
150
|
+
clips: { count: 5, duration: 2 }
|
|
151
|
+
},
|
|
152
|
+
discord: {
|
|
153
|
+
format: "webp",
|
|
154
|
+
width: 360,
|
|
155
|
+
fps: 12,
|
|
156
|
+
clips: { count: 4, duration: 2 }
|
|
157
|
+
},
|
|
158
|
+
"high-quality": {
|
|
159
|
+
format: "webp",
|
|
160
|
+
width: 480,
|
|
161
|
+
fps: 15,
|
|
162
|
+
clips: { count: 6, duration: 2 }
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
var manualSegmentSchema = z.object({
|
|
166
|
+
start: z.number().finite().min(0),
|
|
167
|
+
duration: z.number().finite().positive()
|
|
168
|
+
});
|
|
169
|
+
var optionsSchema = z.object({
|
|
170
|
+
input: z.string().min(1, "input is required"),
|
|
171
|
+
output: z.string().min(1, "output is required"),
|
|
172
|
+
format: z.enum(["webp", "gif", "mp4"]).optional(),
|
|
173
|
+
preset: z.enum(["tiny", "web", "discord", "high-quality"]).optional(),
|
|
174
|
+
strategy: z.enum(["evenly-spaced", "random", "manual"]).optional(),
|
|
175
|
+
clips: z.object({
|
|
176
|
+
count: z.number().int().positive().optional(),
|
|
177
|
+
duration: z.number().finite().positive().optional(),
|
|
178
|
+
range: z.tuple([z.number().finite(), z.number().finite()]).refine(([start, end]) => start >= 0 && end <= 1 && start < end, {
|
|
179
|
+
message: "range must be [start,end] normalized between 0 and 1 with start < end"
|
|
180
|
+
}).optional(),
|
|
181
|
+
segments: z.array(manualSegmentSchema).nonempty().optional()
|
|
182
|
+
}).optional(),
|
|
183
|
+
width: z.number().int().positive().optional(),
|
|
184
|
+
height: z.number().int().positive().optional(),
|
|
185
|
+
fps: z.number().finite().positive().optional(),
|
|
186
|
+
speed: z.number().finite().positive().optional(),
|
|
187
|
+
overwrite: z.boolean().optional(),
|
|
188
|
+
keepTemp: z.boolean().optional(),
|
|
189
|
+
ffmpegPath: z.string().min(1).optional(),
|
|
190
|
+
ffprobePath: z.string().min(1).optional()
|
|
191
|
+
});
|
|
192
|
+
function resolveOptions(options) {
|
|
193
|
+
const parsed = optionsSchema.safeParse(options);
|
|
194
|
+
if (!parsed.success) {
|
|
195
|
+
const message = parsed.error.issues.map((issue) => issue.message).join("; ");
|
|
196
|
+
throw new VidPeekError(`Invalid VidPeek options: ${message}`, {
|
|
197
|
+
code: "INVALID_OPTIONS"
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const presetName = parsed.data.preset ?? "web";
|
|
201
|
+
const preset = PRESETS[presetName];
|
|
202
|
+
const clips = {
|
|
203
|
+
count: parsed.data.clips?.count ?? preset.clips.count,
|
|
204
|
+
duration: parsed.data.clips?.duration ?? preset.clips.duration,
|
|
205
|
+
range: parsed.data.clips?.range ?? [0.05, 0.95],
|
|
206
|
+
segments: parsed.data.clips?.segments
|
|
207
|
+
};
|
|
208
|
+
const strategy = parsed.data.strategy ?? "evenly-spaced";
|
|
209
|
+
if (strategy === "manual" && !clips.segments?.length) {
|
|
210
|
+
throw new VidPeekError("Manual strategy requires clips.segments.", {
|
|
211
|
+
code: "MISSING_MANUAL_SEGMENTS"
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
...parsed.data,
|
|
216
|
+
format: parsed.data.format ?? preset.format,
|
|
217
|
+
preset: presetName,
|
|
218
|
+
strategy,
|
|
219
|
+
clips,
|
|
220
|
+
width: parsed.data.width ?? preset.width,
|
|
221
|
+
fps: parsed.data.fps ?? preset.fps,
|
|
222
|
+
speed: parsed.data.speed ?? 1,
|
|
223
|
+
overwrite: parsed.data.overwrite ?? false,
|
|
224
|
+
keepTemp: parsed.data.keepTemp ?? false,
|
|
225
|
+
ffmpegPath: parsed.data.ffmpegPath ?? "ffmpeg",
|
|
226
|
+
ffprobePath: parsed.data.ffprobePath ?? "ffprobe"
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/core/segments.ts
|
|
231
|
+
function clamp(value, min, max) {
|
|
232
|
+
return Math.min(Math.max(value, min), max);
|
|
233
|
+
}
|
|
234
|
+
function clampSegment(segment, duration, index) {
|
|
235
|
+
if (segment.duration <= 0) {
|
|
236
|
+
throw new VidPeekError("Segment duration must be greater than zero.", {
|
|
237
|
+
code: "INVALID_SEGMENT"
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (segment.start < 0) {
|
|
241
|
+
throw new VidPeekError("Segment start must be zero or greater.", {
|
|
242
|
+
code: "INVALID_SEGMENT"
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (segment.start >= duration) {
|
|
246
|
+
throw new VidPeekError("Segment start must be before the end of the video.", {
|
|
247
|
+
code: "INVALID_SEGMENT"
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const start = clamp(segment.start, 0, Math.max(duration - 1e-3, 0));
|
|
251
|
+
const availableDuration = Math.max(duration - start, 1e-3);
|
|
252
|
+
return {
|
|
253
|
+
index,
|
|
254
|
+
start,
|
|
255
|
+
duration: Math.min(segment.duration, availableDuration)
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function selectEvenlySpaced(duration, count, clipDuration, range) {
|
|
259
|
+
const maxStart = Math.max(duration - Math.min(clipDuration, duration), 0);
|
|
260
|
+
const rangeStart = clamp(duration * range[0], 0, maxStart);
|
|
261
|
+
const rangeEnd = clamp(duration * range[1], rangeStart, maxStart);
|
|
262
|
+
const window = rangeEnd - rangeStart;
|
|
263
|
+
return Array.from({ length: count }, (_, index) => {
|
|
264
|
+
const start = count === 1 ? rangeStart + window / 2 : rangeStart + window * index / (count - 1);
|
|
265
|
+
return clampSegment({ start, duration: clipDuration }, duration, index);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function selectRandom(duration, count, clipDuration, range) {
|
|
269
|
+
const maxStart = Math.max(duration - Math.min(clipDuration, duration), 0);
|
|
270
|
+
const rangeStart = clamp(duration * range[0], 0, maxStart);
|
|
271
|
+
const rangeEnd = clamp(duration * range[1], rangeStart, maxStart);
|
|
272
|
+
return Array.from({ length: count }, (_, index) => {
|
|
273
|
+
const start = rangeStart + Math.random() * (rangeEnd - rangeStart);
|
|
274
|
+
return clampSegment({ start, duration: clipDuration }, duration, index);
|
|
275
|
+
}).sort((a, b) => a.start - b.start);
|
|
276
|
+
}
|
|
277
|
+
function selectSegments(duration, options) {
|
|
278
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
279
|
+
throw new VidPeekError("Video duration must be greater than zero.", {
|
|
280
|
+
code: "INVALID_DURATION"
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (options.strategy === "manual") {
|
|
284
|
+
if (!options.clips.segments?.length) {
|
|
285
|
+
throw new VidPeekError("Manual strategy requires clips.segments.", {
|
|
286
|
+
code: "MISSING_MANUAL_SEGMENTS"
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return options.clips.segments.map(
|
|
290
|
+
(segment, index) => clampSegment(segment, duration, index)
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (options.strategy === "random") {
|
|
294
|
+
return selectRandom(
|
|
295
|
+
duration,
|
|
296
|
+
options.clips.count,
|
|
297
|
+
options.clips.duration,
|
|
298
|
+
options.clips.range
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return selectEvenlySpaced(
|
|
302
|
+
duration,
|
|
303
|
+
options.clips.count,
|
|
304
|
+
options.clips.duration,
|
|
305
|
+
options.clips.range
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/core/temp.ts
|
|
310
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
311
|
+
import { tmpdir } from "os";
|
|
312
|
+
import path from "path";
|
|
313
|
+
async function createTempDir() {
|
|
314
|
+
return mkdtemp(path.join(tmpdir(), "vidpeek-"));
|
|
315
|
+
}
|
|
316
|
+
async function cleanupTempDir(tempDir) {
|
|
317
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/core/generate-preview.ts
|
|
321
|
+
function formatSeconds(value) {
|
|
322
|
+
return value.toFixed(3);
|
|
323
|
+
}
|
|
324
|
+
function buildVideoFilters(options) {
|
|
325
|
+
const filters = [];
|
|
326
|
+
if (options.fps) {
|
|
327
|
+
filters.push(`fps=${options.fps}`);
|
|
328
|
+
}
|
|
329
|
+
if (options.width && options.height) {
|
|
330
|
+
filters.push(`scale=${options.width}:${options.height}`);
|
|
331
|
+
} else if (options.width) {
|
|
332
|
+
filters.push(`scale=${options.width}:-2`);
|
|
333
|
+
} else if (options.height) {
|
|
334
|
+
filters.push(`scale=-2:${options.height}`);
|
|
335
|
+
}
|
|
336
|
+
if (options.speed && options.speed !== 1) {
|
|
337
|
+
filters.push(`setpts=${1 / options.speed}*PTS`);
|
|
338
|
+
}
|
|
339
|
+
return filters;
|
|
340
|
+
}
|
|
341
|
+
function overwriteArgs(overwrite) {
|
|
342
|
+
return [overwrite ? "-y" : "-n"];
|
|
343
|
+
}
|
|
344
|
+
function concatFilePath(filePath) {
|
|
345
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
346
|
+
return `file '${normalized.replace(/'/g, "'\\''")}'`;
|
|
347
|
+
}
|
|
348
|
+
async function assertCanWriteOutput(output, overwrite) {
|
|
349
|
+
if (overwrite) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
await access(output);
|
|
354
|
+
} catch {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
throw new VidPeekError(`Output already exists: ${output}. Pass overwrite: true to replace it.`, {
|
|
358
|
+
code: "OUTPUT_EXISTS"
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
async function fileSize(filePath) {
|
|
362
|
+
return (await stat(filePath)).size;
|
|
363
|
+
}
|
|
364
|
+
async function transcodeFinal(options) {
|
|
365
|
+
const args = [...overwriteArgs(options.overwrite), "-i", options.input, "-an"];
|
|
366
|
+
if (options.filters.length > 0) {
|
|
367
|
+
args.push("-vf", options.filters.join(","));
|
|
368
|
+
}
|
|
369
|
+
if (options.format === "webp") {
|
|
370
|
+
args.push("-loop", "0", "-c:v", "libwebp", "-quality", "80", options.output);
|
|
371
|
+
} else if (options.format === "gif") {
|
|
372
|
+
args.push("-f", "gif", options.output);
|
|
373
|
+
} else {
|
|
374
|
+
args.push(
|
|
375
|
+
"-c:v",
|
|
376
|
+
"libx264",
|
|
377
|
+
"-pix_fmt",
|
|
378
|
+
"yuv420p",
|
|
379
|
+
"-movflags",
|
|
380
|
+
"+faststart",
|
|
381
|
+
options.output
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
await runFfmpeg(args, options.ffmpegPath);
|
|
385
|
+
}
|
|
386
|
+
async function generatePreview(options) {
|
|
387
|
+
const startedAt = performance.now();
|
|
388
|
+
const resolved = resolveOptions(options);
|
|
389
|
+
const tempDir = await createTempDir();
|
|
390
|
+
try {
|
|
391
|
+
await assertCanWriteOutput(resolved.output, resolved.overwrite);
|
|
392
|
+
await mkdir(path2.dirname(resolved.output), { recursive: true });
|
|
393
|
+
const metadata = await probeVideo(resolved.input, resolved.ffprobePath);
|
|
394
|
+
const segments = selectSegments(metadata.duration, resolved);
|
|
395
|
+
const clipPaths = [];
|
|
396
|
+
for (const segment of segments) {
|
|
397
|
+
const clipPath = path2.join(tempDir, `clip-${String(segment.index + 1).padStart(3, "0")}.mp4`);
|
|
398
|
+
await runFfmpeg(
|
|
399
|
+
[
|
|
400
|
+
"-y",
|
|
401
|
+
"-ss",
|
|
402
|
+
formatSeconds(segment.start),
|
|
403
|
+
"-t",
|
|
404
|
+
formatSeconds(segment.duration),
|
|
405
|
+
"-i",
|
|
406
|
+
resolved.input,
|
|
407
|
+
"-an",
|
|
408
|
+
"-c:v",
|
|
409
|
+
"libx264",
|
|
410
|
+
"-preset",
|
|
411
|
+
"veryfast",
|
|
412
|
+
"-pix_fmt",
|
|
413
|
+
"yuv420p",
|
|
414
|
+
clipPath
|
|
415
|
+
],
|
|
416
|
+
resolved.ffmpegPath
|
|
417
|
+
);
|
|
418
|
+
clipPaths.push(clipPath);
|
|
419
|
+
}
|
|
420
|
+
const fileListPath = path2.join(tempDir, "filelist.txt");
|
|
421
|
+
await writeFile(fileListPath, `${clipPaths.map(concatFilePath).join("\n")}
|
|
422
|
+
`, "utf8");
|
|
423
|
+
const mergedPath = path2.join(tempDir, "merged.mp4");
|
|
424
|
+
await runFfmpeg(
|
|
425
|
+
["-y", "-f", "concat", "-safe", "0", "-i", fileListPath, "-c", "copy", mergedPath],
|
|
426
|
+
resolved.ffmpegPath
|
|
427
|
+
);
|
|
428
|
+
await transcodeFinal({
|
|
429
|
+
input: mergedPath,
|
|
430
|
+
output: resolved.output,
|
|
431
|
+
format: resolved.format,
|
|
432
|
+
filters: buildVideoFilters(resolved),
|
|
433
|
+
overwrite: resolved.overwrite,
|
|
434
|
+
ffmpegPath: resolved.ffmpegPath
|
|
435
|
+
});
|
|
436
|
+
await fileSize(resolved.output);
|
|
437
|
+
return {
|
|
438
|
+
output: resolved.output,
|
|
439
|
+
format: resolved.format,
|
|
440
|
+
duration: metadata.duration,
|
|
441
|
+
segments,
|
|
442
|
+
elapsedMs: Math.round(performance.now() - startedAt)
|
|
443
|
+
};
|
|
444
|
+
} catch (error) {
|
|
445
|
+
throw toVidPeekError(error);
|
|
446
|
+
} finally {
|
|
447
|
+
if (!resolved.keepTemp) {
|
|
448
|
+
await cleanupTempDir(tempDir);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export {
|
|
454
|
+
VidPeekError,
|
|
455
|
+
toVidPeekError,
|
|
456
|
+
generatePreview
|
|
457
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
generatePreview,
|
|
4
|
+
toVidPeekError
|
|
5
|
+
} from "./chunk-KVP6XIDZ.js";
|
|
6
|
+
|
|
7
|
+
// src/cli.ts
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
function parsePositiveNumber(value, name) {
|
|
10
|
+
const parsed = Number(value);
|
|
11
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
12
|
+
throw new Error(`${name} must be a positive number.`);
|
|
13
|
+
}
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
function parsePositiveInteger(value, name) {
|
|
17
|
+
const parsed = Number(value);
|
|
18
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
19
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
function parseRange(value) {
|
|
24
|
+
const parts = value.split(",").map((part) => Number(part.trim()));
|
|
25
|
+
if (parts.length !== 2 || parts.some((part) => !Number.isFinite(part)) || parts[0] < 0 || parts[1] > 1 || parts[0] >= parts[1]) {
|
|
26
|
+
throw new Error("--range must be formatted as start,end with values from 0 to 1.");
|
|
27
|
+
}
|
|
28
|
+
return [parts[0], parts[1]];
|
|
29
|
+
}
|
|
30
|
+
function toGenerateOptions(input, cli) {
|
|
31
|
+
if (!cli.out) {
|
|
32
|
+
throw new Error("--out is required.");
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
input,
|
|
36
|
+
output: cli.out,
|
|
37
|
+
preset: cli.preset,
|
|
38
|
+
strategy: cli.strategy,
|
|
39
|
+
format: cli.format,
|
|
40
|
+
clips: {
|
|
41
|
+
count: cli.clips ? parsePositiveInteger(cli.clips, "--clips") : void 0,
|
|
42
|
+
duration: cli.clipDuration ? parsePositiveNumber(cli.clipDuration, "--clip-duration") : void 0,
|
|
43
|
+
range: cli.range ? parseRange(cli.range) : void 0
|
|
44
|
+
},
|
|
45
|
+
width: cli.width ? parsePositiveInteger(cli.width, "--width") : void 0,
|
|
46
|
+
height: cli.height ? parsePositiveInteger(cli.height, "--height") : void 0,
|
|
47
|
+
fps: cli.fps ? parsePositiveNumber(cli.fps, "--fps") : void 0,
|
|
48
|
+
speed: cli.speed ? parsePositiveNumber(cli.speed, "--speed") : void 0,
|
|
49
|
+
overwrite: cli.overwrite,
|
|
50
|
+
keepTemp: cli.keepTemp,
|
|
51
|
+
ffmpegPath: cli.ffmpegPath,
|
|
52
|
+
ffprobePath: cli.ffprobePath
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
var program = new Command();
|
|
56
|
+
program.name("vidpeek").description("A modern typed FFmpeg preview generator for Node and CLI.").argument("<input>", "input video path").requiredOption("--out <path>", "output preview path").option("--preset <preset>", "tiny, web, discord, or high-quality").option("--strategy <strategy>", "evenly-spaced, random, or manual").option("--clips <number>", "number of clips to sample").option("--clip-duration <seconds>", "duration of each sampled clip").option("--range <start,end>", "normalized sampling range, for example 0.05,0.95").option("--width <number>", "output width").option("--height <number>", "output height").option("--fps <number>", "output frames per second").option("--speed <number>", "preview speed multiplier").option("--format <format>", "webp, gif, or mp4").option("--overwrite", "replace output if it already exists").option("--keep-temp", "keep temporary working directory").option("--ffmpeg-path <path>", "custom ffmpeg binary path").option("--ffprobe-path <path>", "custom ffprobe binary path").option("--json", "print machine-readable result JSON").action(async (input, cli) => {
|
|
57
|
+
try {
|
|
58
|
+
const result = await generatePreview(toGenerateOptions(input, cli));
|
|
59
|
+
if (cli.json) {
|
|
60
|
+
console.log(JSON.stringify(result, null, 2));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
console.log(`VidPeek generated ${result.format} preview: ${result.output}`);
|
|
64
|
+
console.log(`Duration: ${result.duration.toFixed(2)}s`);
|
|
65
|
+
console.log(`Segments: ${result.segments.length}`);
|
|
66
|
+
console.log(`Elapsed: ${result.elapsedMs}ms`);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const vidPeekError = toVidPeekError(error);
|
|
69
|
+
if (cli.json) {
|
|
70
|
+
console.error(
|
|
71
|
+
JSON.stringify(
|
|
72
|
+
{
|
|
73
|
+
error: vidPeekError.message,
|
|
74
|
+
code: vidPeekError.code,
|
|
75
|
+
command: vidPeekError.command,
|
|
76
|
+
exitCode: vidPeekError.exitCode
|
|
77
|
+
},
|
|
78
|
+
null,
|
|
79
|
+
2
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
console.error(`VidPeek error: ${vidPeekError.message}`);
|
|
84
|
+
}
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
await program.parseAsync();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
type PreviewFormat = "webp" | "gif" | "mp4";
|
|
2
|
+
type SegmentStrategy = "evenly-spaced" | "random" | "manual";
|
|
3
|
+
type VidPeekPreset = "tiny" | "web" | "discord" | "high-quality";
|
|
4
|
+
interface ManualSegment {
|
|
5
|
+
start: number;
|
|
6
|
+
duration: number;
|
|
7
|
+
}
|
|
8
|
+
interface GeneratePreviewOptions {
|
|
9
|
+
input: string;
|
|
10
|
+
output: string;
|
|
11
|
+
format?: PreviewFormat;
|
|
12
|
+
preset?: VidPeekPreset;
|
|
13
|
+
strategy?: SegmentStrategy;
|
|
14
|
+
clips?: {
|
|
15
|
+
count?: number;
|
|
16
|
+
duration?: number;
|
|
17
|
+
range?: [number, number];
|
|
18
|
+
segments?: ManualSegment[];
|
|
19
|
+
};
|
|
20
|
+
width?: number;
|
|
21
|
+
height?: number;
|
|
22
|
+
fps?: number;
|
|
23
|
+
speed?: number;
|
|
24
|
+
overwrite?: boolean;
|
|
25
|
+
keepTemp?: boolean;
|
|
26
|
+
ffmpegPath?: string;
|
|
27
|
+
ffprobePath?: string;
|
|
28
|
+
}
|
|
29
|
+
interface SelectedSegment extends ManualSegment {
|
|
30
|
+
index: number;
|
|
31
|
+
}
|
|
32
|
+
interface GeneratePreviewResult {
|
|
33
|
+
output: string;
|
|
34
|
+
format: PreviewFormat;
|
|
35
|
+
duration: number;
|
|
36
|
+
segments: SelectedSegment[];
|
|
37
|
+
elapsedMs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
declare function generatePreview(options: GeneratePreviewOptions): Promise<GeneratePreviewResult>;
|
|
41
|
+
|
|
42
|
+
interface VidPeekErrorOptions {
|
|
43
|
+
code?: string;
|
|
44
|
+
command?: string;
|
|
45
|
+
exitCode?: number | null;
|
|
46
|
+
stderr?: string;
|
|
47
|
+
cause?: unknown;
|
|
48
|
+
}
|
|
49
|
+
declare class VidPeekError extends Error {
|
|
50
|
+
readonly code?: string;
|
|
51
|
+
readonly command?: string;
|
|
52
|
+
readonly exitCode?: number | null;
|
|
53
|
+
readonly stderr?: string;
|
|
54
|
+
constructor(message: string, options?: VidPeekErrorOptions);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { type GeneratePreviewOptions, type GeneratePreviewResult, type ManualSegment, type PreviewFormat, type SegmentStrategy, type SelectedSegment, VidPeekError, type VidPeekPreset, generatePreview };
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vidpeek",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A modern typed FFmpeg preview generator for Node and CLI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"vidpeek": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/postigodev/vidpeek.git"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
|
|
28
|
+
"dev": "tsx src/cli.ts",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"bench": "tsx benchmarks/run.ts",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"ffmpeg",
|
|
35
|
+
"video",
|
|
36
|
+
"preview",
|
|
37
|
+
"webp",
|
|
38
|
+
"gif",
|
|
39
|
+
"cli"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"packageManager": "pnpm@9.15.0",
|
|
45
|
+
"author": "",
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"commander": "^12.1.0",
|
|
49
|
+
"zod": "^3.23.8"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^22.1.0",
|
|
53
|
+
"tsx": "^4.16.5",
|
|
54
|
+
"tsup": "^8.2.4",
|
|
55
|
+
"typescript": "^5.5.4",
|
|
56
|
+
"vitest": "^2.0.5"
|
|
57
|
+
}
|
|
58
|
+
}
|