mpx-video 1.0.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 +14 -0
- package/README.md +120 -0
- package/bin/cli.js +126 -0
- package/package.json +28 -0
- package/src/commands/batch.js +36 -0
- package/src/commands/compress.js +44 -0
- package/src/commands/convert.js +39 -0
- package/src/commands/extract-audio.js +24 -0
- package/src/commands/gif.js +25 -0
- package/src/commands/info.js +52 -0
- package/src/commands/merge.js +30 -0
- package/src/commands/resize.js +26 -0
- package/src/commands/speed.js +28 -0
- package/src/commands/thumbnail.js +40 -0
- package/src/commands/trim.js +42 -0
- package/src/commands/watermark.js +36 -0
- package/src/license.js +65 -0
- package/src/utils/ffmpeg.js +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mpx-video — Dual License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mesaplex
|
|
4
|
+
|
|
5
|
+
FREE TIER:
|
|
6
|
+
Permission is granted to use this software free of charge for personal
|
|
7
|
+
and evaluation purposes, subject to the daily usage limits built into
|
|
8
|
+
the software.
|
|
9
|
+
|
|
10
|
+
PRO TIER:
|
|
11
|
+
A paid license removes all usage limits and unlocks additional features.
|
|
12
|
+
Purchase at https://mesaplex.com/mpx-video
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# mpx-video
|
|
2
|
+
|
|
3
|
+
**ffmpeg for humans** — Simple, beautiful video processing from the command line.
|
|
4
|
+
|
|
5
|
+
Stop memorizing ffmpeg flags. `mpx-video` wraps ffmpeg with human-friendly commands, smart defaults, and beautiful output.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g mpx-video
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires [ffmpeg](https://ffmpeg.org/download.html) installed on your system.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Get video info
|
|
19
|
+
mpx-video info video.mp4
|
|
20
|
+
|
|
21
|
+
# Compress a video (smart defaults, ~50% size reduction)
|
|
22
|
+
mpx-video compress video.mp4
|
|
23
|
+
|
|
24
|
+
# Convert format
|
|
25
|
+
mpx-video convert video.mp4 webm
|
|
26
|
+
|
|
27
|
+
# Trim a clip
|
|
28
|
+
mpx-video trim video.mp4 --start 1:30 --end 2:00
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
### Free Tier (5/day)
|
|
34
|
+
|
|
35
|
+
| Command | Description |
|
|
36
|
+
|---------|-------------|
|
|
37
|
+
| `info <file>` | Show duration, resolution, codec, bitrate, file size |
|
|
38
|
+
| `compress <file> [output]` | Smart compression with configurable quality |
|
|
39
|
+
| `convert <file> <format>` | Convert between mp4, webm, mov, avi, mkv, gif |
|
|
40
|
+
| `trim <file>` | Cut video segments with human-friendly times |
|
|
41
|
+
|
|
42
|
+
### Pro Tier (unlimited)
|
|
43
|
+
|
|
44
|
+
| Command | Description |
|
|
45
|
+
|---------|-------------|
|
|
46
|
+
| `extract-audio <file>` | Extract audio as mp3, wav, aac, or flac |
|
|
47
|
+
| `resize <file>` | Scale video to target width/height |
|
|
48
|
+
| `gif <file>` | Create GIF from video segment |
|
|
49
|
+
| `thumbnail <file>` | Generate thumbnail grid (4x4 default) |
|
|
50
|
+
| `speed <file>` | Speed up or slow down playback |
|
|
51
|
+
| `merge <files...>` | Concatenate multiple videos |
|
|
52
|
+
| `watermark <file>` | Add text watermark |
|
|
53
|
+
| `batch <dir>` | Batch process entire directories |
|
|
54
|
+
|
|
55
|
+
## Examples
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Compress with high quality
|
|
59
|
+
mpx-video compress video.mp4 -q high
|
|
60
|
+
|
|
61
|
+
# Convert to GIF
|
|
62
|
+
mpx-video convert video.mp4 gif
|
|
63
|
+
|
|
64
|
+
# Trim with duration
|
|
65
|
+
mpx-video trim video.mp4 --start 0:30 --duration 60s
|
|
66
|
+
|
|
67
|
+
# Extract audio as WAV
|
|
68
|
+
mpx-video extract-audio video.mp4 -f wav
|
|
69
|
+
|
|
70
|
+
# Create 3-second GIF starting at 5s
|
|
71
|
+
mpx-video gif video.mp4 --start 5 --duration 3
|
|
72
|
+
|
|
73
|
+
# 2x speed
|
|
74
|
+
mpx-video speed video.mp4 --factor 2
|
|
75
|
+
|
|
76
|
+
# Resize to 720p
|
|
77
|
+
mpx-video resize video.mp4 --width 1280
|
|
78
|
+
|
|
79
|
+
# Add watermark
|
|
80
|
+
mpx-video watermark video.mp4 --text "© 2026" --position top-right
|
|
81
|
+
|
|
82
|
+
# Batch compress a folder
|
|
83
|
+
mpx-video batch ./videos --action compress
|
|
84
|
+
|
|
85
|
+
# JSON output for CI/CD
|
|
86
|
+
mpx-video info video.mp4 --json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Time Formats
|
|
90
|
+
|
|
91
|
+
All time arguments accept human-friendly formats:
|
|
92
|
+
|
|
93
|
+
- `90` or `90s` — 90 seconds
|
|
94
|
+
- `1:30` — 1 minute 30 seconds
|
|
95
|
+
- `01:30:00` — 1 hour 30 minutes
|
|
96
|
+
|
|
97
|
+
## Licensing
|
|
98
|
+
|
|
99
|
+
**Free tier:** 5 operations per day. Basic commands (info, compress, convert, trim).
|
|
100
|
+
|
|
101
|
+
**Pro tier:** Unlimited operations. All commands including batch, extract-audio, gif, resize, speed, merge, watermark, thumbnail.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Check license status
|
|
105
|
+
mpx-video license
|
|
106
|
+
|
|
107
|
+
# Activate Pro
|
|
108
|
+
mpx-video activate MPX-PRO-XXXXXXXX
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Get Pro at [mesaplex.com/mpx-video](https://mesaplex.com/mpx-video)
|
|
112
|
+
|
|
113
|
+
## Requirements
|
|
114
|
+
|
|
115
|
+
- Node.js >= 16
|
|
116
|
+
- ffmpeg and ffprobe installed
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
Dual license — Free tier for personal use, Pro license for commercial and unlimited use.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const { requireFfmpeg } = require('../src/utils/ffmpeg');
|
|
6
|
+
const { getLicense, activateLicense, checkRateLimit, recordUsage, FREE_DAILY_LIMIT } = require('../src/license');
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program.name('mpx-video').description('ffmpeg for humans — simple video processing CLI').version('1.0.0');
|
|
10
|
+
|
|
11
|
+
function requireFile(filePath) {
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
if (!fs.existsSync(filePath)) { console.error(chalk.red(` ✗ File not found: ${filePath}`)); process.exit(1); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function gate(pro = false) {
|
|
17
|
+
requireFfmpeg();
|
|
18
|
+
const license = getLicense();
|
|
19
|
+
if (pro && license.tier !== 'pro') {
|
|
20
|
+
console.error(chalk.yellow('\n ⚡ Pro feature — upgrade at https://mesaplex.com/mpx-video\n'));
|
|
21
|
+
console.error(chalk.dim(' Activate with: mpx-video activate <license-key>\n'));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const limit = checkRateLimit();
|
|
25
|
+
if (!limit.allowed) {
|
|
26
|
+
console.error(chalk.yellow(`\n Daily limit reached (${FREE_DAILY_LIMIT}/day). Resets tomorrow.`));
|
|
27
|
+
console.error(chalk.dim(' Upgrade to Pro for unlimited: https://mesaplex.com/mpx-video\n'));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
if (limit.remaining > 0) console.log(chalk.dim(` Free tier: ${limit.remaining} operation(s) remaining today`));
|
|
31
|
+
recordUsage();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Free tier commands
|
|
35
|
+
program.command('info <file>').description('Show video file information').option('--json', 'JSON output').action(async (file, opts) => {
|
|
36
|
+
requireFfmpeg(); requireFile(file);
|
|
37
|
+
await require('../src/commands/info')(file, opts);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
program.command('compress <file> [output]').description('Smart video compression').option('-q, --quality <level>', 'Quality: high, medium, low', 'medium').option('--fast', 'Fast encoding (larger file)').action(async (file, output, opts) => {
|
|
41
|
+
requireFile(file); gate();
|
|
42
|
+
await require('../src/commands/compress')(file, output, opts);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
program.command('convert <file> <format>').description('Convert video format (mp4, webm, mov, avi, mkv, gif)').option('-o, --output <path>', 'Output path').action(async (file, format, opts) => {
|
|
46
|
+
requireFile(file); gate();
|
|
47
|
+
await require('../src/commands/convert')(file, format, opts);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
program.command('trim <file>').description('Trim video segment').option('-s, --start <time>', 'Start time (0:30, 90s, 1:30:00)').option('-e, --end <time>', 'End time').option('-d, --duration <time>', 'Duration from start').option('-o, --output <path>', 'Output path').action(async (file, opts) => {
|
|
51
|
+
requireFile(file); gate();
|
|
52
|
+
await require('../src/commands/trim')(file, opts);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Pro commands
|
|
56
|
+
program.command('extract-audio <file>').description('Extract audio track [Pro]').option('-f, --format <fmt>', 'Audio format: mp3, wav, aac, flac', 'mp3').option('-o, --output <path>', 'Output path').action(async (file, opts) => {
|
|
57
|
+
requireFile(file); gate(true);
|
|
58
|
+
await require('../src/commands/extract-audio')(file, opts);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
program.command('resize <file>').description('Resize/scale video [Pro]').option('-w, --width <px>', 'Target width').option('-h, --height <px>', 'Target height', '-1').option('-o, --output <path>', 'Output path').action(async (file, opts) => {
|
|
62
|
+
requireFile(file); gate(true);
|
|
63
|
+
await require('../src/commands/resize')(file, opts);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
program.command('gif <file>').description('Create GIF from video [Pro]').option('-s, --start <time>', 'Start time', '0').option('-d, --duration <time>', 'Duration in seconds', '5').option('-w, --width <px>', 'GIF width', '480').option('--fps <n>', 'Frames per second', '15').option('-o, --output <path>', 'Output path').action(async (file, opts) => {
|
|
67
|
+
requireFile(file); gate(true);
|
|
68
|
+
await require('../src/commands/gif')(file, opts);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
program.command('thumbnail <file>').description('Generate thumbnail grid [Pro]').option('--cols <n>', 'Grid columns', '4').option('--rows <n>', 'Grid rows', '4').option('-o, --output <path>', 'Output path').action(async (file, opts) => {
|
|
72
|
+
requireFile(file); gate(true);
|
|
73
|
+
await require('../src/commands/thumbnail')(file, opts);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
program.command('speed <file>').description('Change playback speed [Pro]').option('-f, --factor <n>', 'Speed factor (2 = 2x faster, 0.5 = half speed)', '2').option('-o, --output <path>', 'Output path').action(async (file, opts) => {
|
|
77
|
+
requireFile(file); gate(true);
|
|
78
|
+
await require('../src/commands/speed')(file, opts);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
program.command('merge <files...>').description('Concatenate videos [Pro]').option('-o, --output <path>', 'Output path', 'merged-output.mp4').action(async (files, opts) => {
|
|
82
|
+
files.forEach(requireFile); gate(true);
|
|
83
|
+
await require('../src/commands/merge')(files, opts);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
program.command('watermark <file>').description('Add text watermark [Pro]').option('-t, --text <text>', 'Watermark text').option('-p, --position <pos>', 'Position: top-left, top-right, bottom-left, bottom-right, center', 'bottom-right').option('--font-size <px>', 'Font size', '24').option('-o, --output <path>', 'Output path').action(async (file, opts) => {
|
|
87
|
+
requireFile(file); gate(true);
|
|
88
|
+
await require('../src/commands/watermark')(file, opts);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
program.command('batch <dir>').description('Batch process directory [Pro]').option('-a, --action <action>', 'Action: compress, convert, resize').option('-q, --quality <level>', 'Quality for compress').option('-w, --width <px>', 'Width for resize').option('-o, --output <path>', 'Output directory').action(async (dir, opts) => {
|
|
92
|
+
gate(true);
|
|
93
|
+
await require('../src/commands/batch')(dir, opts);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// License management
|
|
97
|
+
program.command('activate <key>').description('Activate Pro license').action((key) => {
|
|
98
|
+
try {
|
|
99
|
+
activateLicense(key);
|
|
100
|
+
console.log(chalk.bold.green('\n ✓ Pro license activated!\n'));
|
|
101
|
+
} catch (err) { console.error(chalk.red(`\n ✗ ${err.message}\n`)); process.exit(1); }
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
program.command('license').description('Check license status').action(() => {
|
|
105
|
+
const l = getLicense();
|
|
106
|
+
const rl = checkRateLimit();
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log(chalk.bold(' License Status'));
|
|
109
|
+
console.log(' ──────────────────────────────────────');
|
|
110
|
+
console.log(' Tier: ' + (l.tier === 'pro' ? chalk.green('Pro') : 'Free'));
|
|
111
|
+
if (l.tier === 'free') {
|
|
112
|
+
console.log(' Limit: ' + FREE_DAILY_LIMIT + ' ops/day');
|
|
113
|
+
console.log(' Today: ' + (FREE_DAILY_LIMIT - (rl.remaining >= 0 ? rl.remaining : 0)) + '/' + FREE_DAILY_LIMIT + ' used');
|
|
114
|
+
} else {
|
|
115
|
+
console.log(' Limit: Unlimited');
|
|
116
|
+
}
|
|
117
|
+
console.log(' ──────────────────────────────────────');
|
|
118
|
+
if (l.tier === 'free') {
|
|
119
|
+
console.log('');
|
|
120
|
+
console.log(' Upgrade: https://mesaplex.com/mpx-video');
|
|
121
|
+
console.log(' Activate: mpx-video activate <license-key>');
|
|
122
|
+
}
|
|
123
|
+
console.log('');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mpx-video",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ffmpeg for humans — simple video processing CLI",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mpx-video": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node test/run.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["ffmpeg", "video", "cli", "compress", "convert", "trim", "developer-tools"],
|
|
13
|
+
"author": "Mesaplex <support@mesaplex.com>",
|
|
14
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/mesaplexdev/mpx-video"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/mesaplexdev/mpx-video#readme",
|
|
20
|
+
"bugs": "https://github.com/mesaplexdev/mpx-video/issues",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"chalk": "4.1.2",
|
|
23
|
+
"commander": "^12.0.0"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=16.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.wmv', '.m4v'];
|
|
6
|
+
|
|
7
|
+
async function batch(dir, opts) {
|
|
8
|
+
const action = opts.action;
|
|
9
|
+
if (!action) { console.error(chalk.red(' ✗ Specify --action (compress|convert|resize)')); process.exit(1); }
|
|
10
|
+
|
|
11
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
12
|
+
console.error(chalk.red(' ✗ Not a directory: ') + dir); process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const files = fs.readdirSync(dir).filter(f => VIDEO_EXTS.includes(path.extname(f).toLowerCase())).map(f => path.join(dir, f));
|
|
16
|
+
|
|
17
|
+
if (files.length === 0) { console.log(chalk.yellow(' No video files found in ' + dir)); return; }
|
|
18
|
+
|
|
19
|
+
console.log('');
|
|
20
|
+
console.log(chalk.bold.cyan(` 📦 Batch ${action}: `) + files.length + ' files');
|
|
21
|
+
|
|
22
|
+
const handler = require(`./${action}`);
|
|
23
|
+
for (let i = 0; i < files.length; i++) {
|
|
24
|
+
console.log(chalk.dim(` [${i + 1}/${files.length}]`));
|
|
25
|
+
try {
|
|
26
|
+
await handler(files[i], opts);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error(chalk.red(` ✗ Failed: ${files[i]}: ${err.message}`));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.bold.green(` ✓ Batch complete: ${files.length} files processed`));
|
|
33
|
+
console.log('');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = batch;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { probe, run, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
async function compress(filePath, output, opts) {
|
|
7
|
+
const ext = path.extname(filePath);
|
|
8
|
+
if (!output) output = filePath.replace(ext, `-compressed${ext}`);
|
|
9
|
+
|
|
10
|
+
const data = probe(filePath);
|
|
11
|
+
const origSize = parseInt(data.format.size || 0);
|
|
12
|
+
const duration = parseFloat(data.format.duration || 0);
|
|
13
|
+
|
|
14
|
+
console.log('');
|
|
15
|
+
console.log(chalk.bold.cyan(' 🎬 Compressing: ') + path.basename(filePath));
|
|
16
|
+
console.log(chalk.dim(' Original: ') + formatBytes(origSize));
|
|
17
|
+
|
|
18
|
+
const crf = opts.quality === 'high' ? '23' : opts.quality === 'low' ? '32' : '28';
|
|
19
|
+
const preset = opts.fast ? 'fast' : 'medium';
|
|
20
|
+
|
|
21
|
+
const args = ['-i', filePath, '-c:v', 'libx264', '-crf', crf, '-preset', preset, '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', '-y', output];
|
|
22
|
+
|
|
23
|
+
const start = Date.now();
|
|
24
|
+
await run(args, {
|
|
25
|
+
onProgress: (secs) => {
|
|
26
|
+
if (duration > 0) {
|
|
27
|
+
const pct = Math.min(100, Math.round((secs / duration) * 100));
|
|
28
|
+
process.stdout.write(`\r Progress: ${chalk.yellow(pct + '%')} ${'█'.repeat(Math.round(pct / 3))}${'░'.repeat(33 - Math.round(pct / 3))}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
34
|
+
const newSize = fs.statSync(output).size;
|
|
35
|
+
const reduction = origSize > 0 ? Math.round((1 - newSize / origSize) * 100) : 0;
|
|
36
|
+
|
|
37
|
+
console.log('');
|
|
38
|
+
console.log(chalk.bold.green(' ✓ Done') + chalk.dim(` in ${elapsed}s`));
|
|
39
|
+
console.log(chalk.dim(' Output: ') + output);
|
|
40
|
+
console.log(chalk.dim(' Size: ') + formatBytes(newSize) + chalk.dim(` (${reduction}% smaller)`));
|
|
41
|
+
console.log('');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = compress;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { run, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
const CODEC_MAP = {
|
|
7
|
+
mp4: ['-c:v', 'libx264', '-c:a', 'aac'],
|
|
8
|
+
webm: ['-c:v', 'libvpx-vp9', '-c:a', 'libopus'],
|
|
9
|
+
mov: ['-c:v', 'libx264', '-c:a', 'aac'],
|
|
10
|
+
avi: ['-c:v', 'libx264', '-c:a', 'mp3'],
|
|
11
|
+
mkv: ['-c:v', 'libx264', '-c:a', 'aac'],
|
|
12
|
+
gif: ['-vf', 'fps=15,scale=480:-1:flags=lanczos', '-an'],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function convert(filePath, format, opts) {
|
|
16
|
+
format = format.replace(/^\./, '').toLowerCase();
|
|
17
|
+
if (!CODEC_MAP[format]) {
|
|
18
|
+
console.error(chalk.red(` ✗ Unsupported format: ${format}`));
|
|
19
|
+
console.error(chalk.dim(` Supported: ${Object.keys(CODEC_MAP).join(', ')}`));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const output = opts.output || filePath.replace(path.extname(filePath), `.${format}`);
|
|
24
|
+
|
|
25
|
+
console.log('');
|
|
26
|
+
console.log(chalk.bold.cyan(' 🔄 Converting: ') + path.basename(filePath) + chalk.dim(` → .${format}`));
|
|
27
|
+
|
|
28
|
+
const args = ['-i', filePath, ...CODEC_MAP[format], '-y', output];
|
|
29
|
+
const start = Date.now();
|
|
30
|
+
await run(args);
|
|
31
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
32
|
+
const newSize = fs.statSync(output).size;
|
|
33
|
+
|
|
34
|
+
console.log(chalk.bold.green(' ✓ Done') + chalk.dim(` in ${elapsed}s`));
|
|
35
|
+
console.log(chalk.dim(' Output: ') + output + chalk.dim(` (${formatBytes(newSize)})`));
|
|
36
|
+
console.log('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = convert;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { run, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
async function extractAudio(filePath, opts) {
|
|
7
|
+
const format = opts.format || 'mp3';
|
|
8
|
+
const output = opts.output || filePath.replace(path.extname(filePath), `.${format}`);
|
|
9
|
+
|
|
10
|
+
console.log('');
|
|
11
|
+
console.log(chalk.bold.cyan(' 🎵 Extracting audio: ') + path.basename(filePath));
|
|
12
|
+
|
|
13
|
+
const codecMap = { mp3: ['-c:a', 'libmp3lame', '-q:a', '2'], wav: ['-c:a', 'pcm_s16le'], aac: ['-c:a', 'aac', '-b:a', '192k'], flac: ['-c:a', 'flac'] };
|
|
14
|
+
const codec = codecMap[format] || codecMap.mp3;
|
|
15
|
+
|
|
16
|
+
await run(['-i', filePath, '-vn', ...codec, '-y', output]);
|
|
17
|
+
const newSize = fs.statSync(output).size;
|
|
18
|
+
|
|
19
|
+
console.log(chalk.bold.green(' ✓ Done'));
|
|
20
|
+
console.log(chalk.dim(' Output: ') + output + chalk.dim(` (${formatBytes(newSize)})`));
|
|
21
|
+
console.log('');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = extractAudio;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { run, parseTime, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
async function gif(filePath, opts) {
|
|
7
|
+
const output = opts.output || filePath.replace(path.extname(filePath), '.gif');
|
|
8
|
+
const start = opts.start ? parseTime(opts.start) : 0;
|
|
9
|
+
const duration = opts.duration ? parseTime(opts.duration) : 5;
|
|
10
|
+
const width = opts.width || 480;
|
|
11
|
+
const fps = opts.fps || 15;
|
|
12
|
+
|
|
13
|
+
console.log('');
|
|
14
|
+
console.log(chalk.bold.cyan(' 🎞️ Creating GIF: ') + path.basename(filePath));
|
|
15
|
+
|
|
16
|
+
const args = ['-i', filePath, '-ss', String(start), '-t', String(duration), '-vf', `fps=${fps},scale=${width}:-1:flags=lanczos`, '-an', '-y', output];
|
|
17
|
+
await run(args);
|
|
18
|
+
const newSize = fs.statSync(output).size;
|
|
19
|
+
|
|
20
|
+
console.log(chalk.bold.green(' ✓ Done'));
|
|
21
|
+
console.log(chalk.dim(' Output: ') + output + chalk.dim(` (${formatBytes(newSize)})`));
|
|
22
|
+
console.log('');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = gif;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const { probe, formatTime, formatBytes } = require('../utils/ffmpeg');
|
|
3
|
+
|
|
4
|
+
async function info(filePath, opts) {
|
|
5
|
+
const data = probe(filePath);
|
|
6
|
+
const fmt = data.format || {};
|
|
7
|
+
const video = data.streams.find(s => s.codec_type === 'video');
|
|
8
|
+
const audio = data.streams.find(s => s.codec_type === 'audio');
|
|
9
|
+
|
|
10
|
+
if (opts.json) {
|
|
11
|
+
console.log(JSON.stringify({ file: filePath, format: fmt.format_long_name, duration: parseFloat(fmt.duration), size: parseInt(fmt.size), video: video ? { codec: video.codec_name, width: video.width, height: video.height, fps: eval(video.r_frame_rate), bitrate: parseInt(video.bit_rate) } : null, audio: audio ? { codec: audio.codec_name, sampleRate: parseInt(audio.sample_rate), channels: audio.channels, bitrate: parseInt(audio.bit_rate) } : null }, null, 2));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const duration = parseFloat(fmt.duration || 0);
|
|
16
|
+
const size = parseInt(fmt.size || 0);
|
|
17
|
+
|
|
18
|
+
console.log('');
|
|
19
|
+
console.log(chalk.bold.cyan('┌─────────────────────────────────────────────────────────────┐'));
|
|
20
|
+
console.log(chalk.bold.cyan('│') + chalk.bold(' mpx-video — File Information ') + chalk.bold.cyan('│'));
|
|
21
|
+
console.log(chalk.bold.cyan('└─────────────────────────────────────────────────────────────┘'));
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log(chalk.bold(' File: ') + filePath);
|
|
24
|
+
console.log(chalk.bold(' Format: ') + (fmt.format_long_name || fmt.format_name || 'unknown'));
|
|
25
|
+
console.log(chalk.bold(' Duration: ') + formatTime(duration));
|
|
26
|
+
console.log(chalk.bold(' Size: ') + formatBytes(size));
|
|
27
|
+
console.log(chalk.bold(' Bitrate: ') + (fmt.bit_rate ? Math.round(fmt.bit_rate / 1000) + ' kbps' : 'unknown'));
|
|
28
|
+
|
|
29
|
+
if (video) {
|
|
30
|
+
console.log('');
|
|
31
|
+
console.log(chalk.bold.yellow(' Video'));
|
|
32
|
+
console.log(' Codec: ' + video.codec_name + (video.profile ? ` (${video.profile})` : ''));
|
|
33
|
+
console.log(' Resolution: ' + video.width + 'x' + video.height);
|
|
34
|
+
let fps = 'unknown';
|
|
35
|
+
try { fps = (eval(video.r_frame_rate)).toFixed(2); } catch {}
|
|
36
|
+
console.log(' FPS: ' + fps);
|
|
37
|
+
if (video.bit_rate) console.log(' Bitrate: ' + Math.round(video.bit_rate / 1000) + ' kbps');
|
|
38
|
+
if (video.pix_fmt) console.log(' Pixel Fmt: ' + video.pix_fmt);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (audio) {
|
|
42
|
+
console.log('');
|
|
43
|
+
console.log(chalk.bold.green(' Audio'));
|
|
44
|
+
console.log(' Codec: ' + audio.codec_name);
|
|
45
|
+
console.log(' Sample: ' + (audio.sample_rate ? audio.sample_rate + ' Hz' : 'unknown'));
|
|
46
|
+
console.log(' Channels: ' + (audio.channels || 'unknown'));
|
|
47
|
+
if (audio.bit_rate) console.log(' Bitrate: ' + Math.round(audio.bit_rate / 1000) + ' kbps');
|
|
48
|
+
}
|
|
49
|
+
console.log('');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = info;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { run, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
async function merge(files, opts) {
|
|
7
|
+
if (!files || files.length < 2) { console.error(chalk.red(' ✗ Need at least 2 files to merge')); process.exit(1); }
|
|
8
|
+
|
|
9
|
+
const output = opts.output || 'merged-output.mp4';
|
|
10
|
+
const listFile = `/tmp/mpx-merge-${Date.now()}.txt`;
|
|
11
|
+
|
|
12
|
+
// Create concat list
|
|
13
|
+
const content = files.map(f => `file '${path.resolve(f)}'`).join('\n');
|
|
14
|
+
fs.writeFileSync(listFile, content);
|
|
15
|
+
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log(chalk.bold.cyan(' 🔗 Merging ') + files.length + ' files');
|
|
18
|
+
files.forEach(f => console.log(chalk.dim(' • ') + path.basename(f)));
|
|
19
|
+
|
|
20
|
+
await run(['-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', '-y', output]);
|
|
21
|
+
|
|
22
|
+
fs.unlinkSync(listFile);
|
|
23
|
+
const newSize = fs.statSync(output).size;
|
|
24
|
+
|
|
25
|
+
console.log(chalk.bold.green(' ✓ Done'));
|
|
26
|
+
console.log(chalk.dim(' Output: ') + output + chalk.dim(` (${formatBytes(newSize)})`));
|
|
27
|
+
console.log('');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = merge;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { run, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
async function resize(filePath, opts) {
|
|
7
|
+
const w = opts.width || -1;
|
|
8
|
+
const h = opts.height || -1;
|
|
9
|
+
if (w === -1 && h === -1) { console.error(chalk.red(' ✗ Specify --width and/or --height')); process.exit(1); }
|
|
10
|
+
|
|
11
|
+
const ext = path.extname(filePath);
|
|
12
|
+
const output = opts.output || filePath.replace(ext, `-resized${ext}`);
|
|
13
|
+
const scale = `scale=${w}:${h}:force_original_aspect_ratio=decrease`;
|
|
14
|
+
|
|
15
|
+
console.log('');
|
|
16
|
+
console.log(chalk.bold.cyan(' 📐 Resizing: ') + path.basename(filePath));
|
|
17
|
+
|
|
18
|
+
await run(['-i', filePath, '-vf', scale, '-c:a', 'copy', '-y', output]);
|
|
19
|
+
const newSize = fs.statSync(output).size;
|
|
20
|
+
|
|
21
|
+
console.log(chalk.bold.green(' ✓ Done'));
|
|
22
|
+
console.log(chalk.dim(' Output: ') + output + chalk.dim(` (${formatBytes(newSize)})`));
|
|
23
|
+
console.log('');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = resize;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { run, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
async function speed(filePath, opts) {
|
|
7
|
+
const factor = parseFloat(opts.factor || 2);
|
|
8
|
+
if (factor <= 0 || factor > 100) { console.error(chalk.red(' ✗ Factor must be between 0.1 and 100')); process.exit(1); }
|
|
9
|
+
|
|
10
|
+
const ext = path.extname(filePath);
|
|
11
|
+
const label = factor > 1 ? `${factor}x faster` : `${(1/factor).toFixed(1)}x slower`;
|
|
12
|
+
const output = opts.output || filePath.replace(ext, `-${factor}x${ext}`);
|
|
13
|
+
|
|
14
|
+
console.log('');
|
|
15
|
+
console.log(chalk.bold.cyan(' ⏩ Speed change: ') + path.basename(filePath) + chalk.dim(` → ${label}`));
|
|
16
|
+
|
|
17
|
+
const vFilter = `setpts=${(1/factor).toFixed(4)}*PTS`;
|
|
18
|
+
const aFilter = `atempo=${Math.min(Math.max(factor, 0.5), 2.0)}`;
|
|
19
|
+
|
|
20
|
+
await run(['-i', filePath, '-vf', vFilter, '-af', aFilter, '-y', output]);
|
|
21
|
+
const newSize = fs.statSync(output).size;
|
|
22
|
+
|
|
23
|
+
console.log(chalk.bold.green(' ✓ Done'));
|
|
24
|
+
console.log(chalk.dim(' Output: ') + output + chalk.dim(` (${formatBytes(newSize)})`));
|
|
25
|
+
console.log('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = speed;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { probe, run, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
async function thumbnail(filePath, opts) {
|
|
7
|
+
const output = opts.output || filePath.replace(path.extname(filePath), '-thumbnails.jpg');
|
|
8
|
+
const cols = opts.cols || 4;
|
|
9
|
+
const rows = opts.rows || 4;
|
|
10
|
+
|
|
11
|
+
const data = probe(filePath);
|
|
12
|
+
const duration = parseFloat(data.format.duration || 0);
|
|
13
|
+
const total = cols * rows;
|
|
14
|
+
const interval = duration / (total + 1);
|
|
15
|
+
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log(chalk.bold.cyan(' 🖼️ Generating thumbnail grid: ') + path.basename(filePath));
|
|
18
|
+
console.log(chalk.dim(` ${cols}x${rows} = ${total} frames`));
|
|
19
|
+
|
|
20
|
+
// Generate individual frames then tile
|
|
21
|
+
const tmpDir = `/tmp/mpx-thumbs-${Date.now()}`;
|
|
22
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
23
|
+
|
|
24
|
+
await run(['-i', filePath, '-vf', `fps=1/${interval.toFixed(2)},scale=320:-1`, '-frames:v', String(total), `${tmpDir}/frame_%03d.jpg`]);
|
|
25
|
+
|
|
26
|
+
// Use ffmpeg tile filter for grid
|
|
27
|
+
await run(['-i', `${tmpDir}/frame_%03d.jpg`, '-vf', `tile=${cols}x${rows}`, '-frames:v', '1', '-y', output]);
|
|
28
|
+
|
|
29
|
+
// Cleanup
|
|
30
|
+
const files = fs.readdirSync(tmpDir);
|
|
31
|
+
files.forEach(f => fs.unlinkSync(path.join(tmpDir, f)));
|
|
32
|
+
fs.rmdirSync(tmpDir);
|
|
33
|
+
|
|
34
|
+
const newSize = fs.statSync(output).size;
|
|
35
|
+
console.log(chalk.bold.green(' ✓ Done'));
|
|
36
|
+
console.log(chalk.dim(' Output: ') + output + chalk.dim(` (${formatBytes(newSize)})`));
|
|
37
|
+
console.log('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = thumbnail;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { run, parseTime, formatTime, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
async function trim(filePath, opts) {
|
|
7
|
+
const start = parseTime(opts.start);
|
|
8
|
+
const end = parseTime(opts.end);
|
|
9
|
+
const duration = opts.duration ? parseTime(opts.duration) : null;
|
|
10
|
+
|
|
11
|
+
if (start === null && end === null && duration === null) {
|
|
12
|
+
console.error(chalk.red(' ✗ Specify --start and/or --end (or --duration)'));
|
|
13
|
+
console.error(chalk.dim(' Example: mpx-video trim video.mp4 --start 0:30 --end 1:00'));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ext = path.extname(filePath);
|
|
18
|
+
const output = opts.output || filePath.replace(ext, `-trimmed${ext}`);
|
|
19
|
+
|
|
20
|
+
console.log('');
|
|
21
|
+
console.log(chalk.bold.cyan(' ✂️ Trimming: ') + path.basename(filePath));
|
|
22
|
+
if (start !== null) console.log(chalk.dim(' Start: ') + formatTime(start));
|
|
23
|
+
if (end !== null) console.log(chalk.dim(' End: ') + formatTime(end));
|
|
24
|
+
if (duration !== null) console.log(chalk.dim(' Duration: ') + formatTime(duration));
|
|
25
|
+
|
|
26
|
+
const args = ['-i', filePath];
|
|
27
|
+
if (start !== null) args.push('-ss', String(start));
|
|
28
|
+
if (end !== null) args.push('-to', String(end));
|
|
29
|
+
if (duration !== null && end === null) args.push('-t', String(duration));
|
|
30
|
+
args.push('-c', 'copy', '-y', output);
|
|
31
|
+
|
|
32
|
+
const t0 = Date.now();
|
|
33
|
+
await run(args);
|
|
34
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
35
|
+
const newSize = fs.statSync(output).size;
|
|
36
|
+
|
|
37
|
+
console.log(chalk.bold.green(' ✓ Done') + chalk.dim(` in ${elapsed}s`));
|
|
38
|
+
console.log(chalk.dim(' Output: ') + output + chalk.dim(` (${formatBytes(newSize)})`));
|
|
39
|
+
console.log('');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = trim;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { run, formatBytes } = require('../utils/ffmpeg');
|
|
5
|
+
|
|
6
|
+
async function watermark(filePath, opts) {
|
|
7
|
+
const text = opts.text;
|
|
8
|
+
if (!text) { console.error(chalk.red(' ✗ Specify --text "Your watermark"')); process.exit(1); }
|
|
9
|
+
|
|
10
|
+
const ext = path.extname(filePath);
|
|
11
|
+
const output = opts.output || filePath.replace(ext, `-watermarked${ext}`);
|
|
12
|
+
const pos = opts.position || 'bottom-right';
|
|
13
|
+
|
|
14
|
+
const posMap = {
|
|
15
|
+
'top-left': 'x=20:y=20',
|
|
16
|
+
'top-right': 'x=w-tw-20:y=20',
|
|
17
|
+
'bottom-left': 'x=20:y=h-th-20',
|
|
18
|
+
'bottom-right': 'x=w-tw-20:y=h-th-20',
|
|
19
|
+
'center': 'x=(w-tw)/2:y=(h-th)/2',
|
|
20
|
+
};
|
|
21
|
+
const xy = posMap[pos] || posMap['bottom-right'];
|
|
22
|
+
const fontSize = opts.fontSize || 24;
|
|
23
|
+
|
|
24
|
+
console.log('');
|
|
25
|
+
console.log(chalk.bold.cyan(' 💧 Adding watermark: ') + `"${text}"`);
|
|
26
|
+
|
|
27
|
+
const drawtext = `drawtext=text='${text.replace(/'/g, "\\'")}':fontsize=${fontSize}:fontcolor=white@0.7:${xy}`;
|
|
28
|
+
await run(['-i', filePath, '-vf', drawtext, '-c:a', 'copy', '-y', output]);
|
|
29
|
+
const newSize = fs.statSync(output).size;
|
|
30
|
+
|
|
31
|
+
console.log(chalk.bold.green(' ✓ Done'));
|
|
32
|
+
console.log(chalk.dim(' Output: ') + output + chalk.dim(` (${formatBytes(newSize)})`));
|
|
33
|
+
console.log('');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = watermark;
|
package/src/license.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const LICENSE_DIR = path.join(os.homedir(), '.mpx-video');
|
|
6
|
+
const LICENSE_FILE = path.join(LICENSE_DIR, 'license.json');
|
|
7
|
+
const USAGE_FILE = path.join(LICENSE_DIR, 'usage.json');
|
|
8
|
+
const FREE_DAILY_LIMIT = 5;
|
|
9
|
+
|
|
10
|
+
function ensureDir() {
|
|
11
|
+
if (!fs.existsSync(LICENSE_DIR)) fs.mkdirSync(LICENSE_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getLicense() {
|
|
15
|
+
ensureDir();
|
|
16
|
+
try {
|
|
17
|
+
if (fs.existsSync(LICENSE_FILE)) {
|
|
18
|
+
const data = JSON.parse(fs.readFileSync(LICENSE_FILE, 'utf8'));
|
|
19
|
+
if (data.key && data.key.startsWith('MPX-PRO-')) {
|
|
20
|
+
return { tier: 'pro', key: data.key, email: data.email || null };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch (err) {}
|
|
24
|
+
return { tier: 'free', key: null, email: null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function activateLicense(key, email = null) {
|
|
28
|
+
ensureDir();
|
|
29
|
+
if (!key.startsWith('MPX-PRO-')) throw new Error('Invalid license key format. Pro keys start with MPX-PRO-');
|
|
30
|
+
fs.writeFileSync(LICENSE_FILE, JSON.stringify({ key, email, activatedAt: new Date().toISOString() }, null, 2));
|
|
31
|
+
return { success: true, tier: 'pro' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function checkRateLimit() {
|
|
35
|
+
const license = getLicense();
|
|
36
|
+
if (license.tier === 'pro') return { allowed: true, remaining: -1 };
|
|
37
|
+
ensureDir();
|
|
38
|
+
let usage = { ops: [], lastReset: null };
|
|
39
|
+
try { if (fs.existsSync(USAGE_FILE)) usage = JSON.parse(fs.readFileSync(USAGE_FILE, 'utf8')); } catch (err) {}
|
|
40
|
+
const today = new Date().toISOString().split('T')[0];
|
|
41
|
+
if (usage.lastReset !== today) usage = { ops: [], lastReset: today };
|
|
42
|
+
const todayOps = usage.ops.filter(s => s.startsWith(today));
|
|
43
|
+
if (todayOps.length >= FREE_DAILY_LIMIT) return { allowed: false, remaining: 0 };
|
|
44
|
+
return { allowed: true, remaining: FREE_DAILY_LIMIT - todayOps.length };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function recordUsage() {
|
|
48
|
+
const license = getLicense();
|
|
49
|
+
if (license.tier === 'pro') return;
|
|
50
|
+
ensureDir();
|
|
51
|
+
let usage = { ops: [], lastReset: null };
|
|
52
|
+
try { if (fs.existsSync(USAGE_FILE)) usage = JSON.parse(fs.readFileSync(USAGE_FILE, 'utf8')); } catch (err) {}
|
|
53
|
+
const today = new Date().toISOString().split('T')[0];
|
|
54
|
+
if (usage.lastReset !== today) usage = { ops: [], lastReset: today };
|
|
55
|
+
usage.ops.push(new Date().toISOString());
|
|
56
|
+
if (usage.ops.length > 20) usage.ops = usage.ops.slice(-20);
|
|
57
|
+
fs.writeFileSync(USAGE_FILE, JSON.stringify(usage, null, 2));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deactivateLicense() {
|
|
61
|
+
if (fs.existsSync(LICENSE_FILE)) fs.unlinkSync(LICENSE_FILE);
|
|
62
|
+
return { success: true, tier: 'free' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { getLicense, activateLicense, deactivateLicense, checkRateLimit, recordUsage, FREE_DAILY_LIMIT };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { execSync, spawn } = require('child_process');
|
|
2
|
+
|
|
3
|
+
function checkFfmpeg() {
|
|
4
|
+
try { execSync('ffmpeg -version', { stdio: 'pipe' }); return true; } catch { return false; }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function checkFfprobe() {
|
|
8
|
+
try { execSync('ffprobe -version', { stdio: 'pipe' }); return true; } catch { return false; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requireFfmpeg() {
|
|
12
|
+
if (!checkFfmpeg() || !checkFfprobe()) {
|
|
13
|
+
console.error('ffmpeg and ffprobe are required but not found.\n');
|
|
14
|
+
console.error('Install ffmpeg:');
|
|
15
|
+
console.error(' macOS: brew install ffmpeg');
|
|
16
|
+
console.error(' Ubuntu: sudo apt install ffmpeg');
|
|
17
|
+
console.error(' Windows: winget install ffmpeg');
|
|
18
|
+
console.error(' Other: https://ffmpeg.org/download.html');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function probe(filePath) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = execSync(
|
|
26
|
+
`ffprobe -v quiet -print_format json -show_format -show_streams "${filePath}"`,
|
|
27
|
+
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
|
|
28
|
+
);
|
|
29
|
+
return JSON.parse(raw);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
throw new Error(`Failed to probe file: ${filePath}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function run(args, { onProgress } = {}) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const proc = spawn('ffmpeg', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
38
|
+
let stderr = '';
|
|
39
|
+
proc.stderr.on('data', d => {
|
|
40
|
+
stderr += d.toString();
|
|
41
|
+
if (onProgress) {
|
|
42
|
+
const match = d.toString().match(/time=(\d+):(\d+):(\d+\.\d+)/);
|
|
43
|
+
if (match) {
|
|
44
|
+
const secs = parseInt(match[1]) * 3600 + parseInt(match[2]) * 60 + parseFloat(match[3]);
|
|
45
|
+
onProgress(secs);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
proc.on('close', code => {
|
|
50
|
+
if (code === 0) resolve(stderr);
|
|
51
|
+
else reject(new Error(`ffmpeg exited with code ${code}\n${stderr.slice(-500)}`));
|
|
52
|
+
});
|
|
53
|
+
proc.on('error', reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseTime(str) {
|
|
58
|
+
if (!str) return null;
|
|
59
|
+
// Accept: 90, 90s, 1:30, 01:30, 1:01:30, 01:01:30
|
|
60
|
+
if (/^\d+(\.\d+)?s?$/.test(str)) return parseFloat(str);
|
|
61
|
+
const parts = str.split(':').map(Number);
|
|
62
|
+
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
63
|
+
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatTime(secs) {
|
|
68
|
+
const h = Math.floor(secs / 3600);
|
|
69
|
+
const m = Math.floor((secs % 3600) / 60);
|
|
70
|
+
const s = (secs % 60).toFixed(1);
|
|
71
|
+
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(4, '0')}`;
|
|
72
|
+
return `${m}:${String(s).padStart(4, '0')}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatBytes(bytes) {
|
|
76
|
+
if (bytes < 1024) return bytes + ' B';
|
|
77
|
+
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
78
|
+
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
79
|
+
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { checkFfmpeg, checkFfprobe, requireFfmpeg, probe, run, parseTime, formatTime, formatBytes };
|