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 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();
@@ -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
@@ -0,0 +1,8 @@
1
+ import {
2
+ VidPeekError,
3
+ generatePreview
4
+ } from "./chunk-KVP6XIDZ.js";
5
+ export {
6
+ VidPeekError,
7
+ generatePreview
8
+ };
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
+ }