noupload 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/.github/workflows/release.yml +73 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/biome.json +34 -0
- package/bunfig.toml +7 -0
- package/dist/index.js +192 -0
- package/install.sh +68 -0
- package/package.json +47 -0
- package/scripts/inspect-help.ts +15 -0
- package/site/index.html +112 -0
- package/src/cli.ts +24 -0
- package/src/commands/audio/convert.ts +107 -0
- package/src/commands/audio/extract.ts +84 -0
- package/src/commands/audio/fade.ts +128 -0
- package/src/commands/audio/index.ts +35 -0
- package/src/commands/audio/merge.ts +109 -0
- package/src/commands/audio/normalize.ts +110 -0
- package/src/commands/audio/reverse.ts +64 -0
- package/src/commands/audio/speed.ts +101 -0
- package/src/commands/audio/trim.ts +98 -0
- package/src/commands/audio/volume.ts +91 -0
- package/src/commands/audio/waveform.ts +117 -0
- package/src/commands/doctor.ts +125 -0
- package/src/commands/image/adjust.ts +129 -0
- package/src/commands/image/border.ts +94 -0
- package/src/commands/image/bulk-compress.ts +111 -0
- package/src/commands/image/bulk-convert.ts +114 -0
- package/src/commands/image/bulk-resize.ts +112 -0
- package/src/commands/image/compress.ts +95 -0
- package/src/commands/image/convert.ts +116 -0
- package/src/commands/image/crop.ts +96 -0
- package/src/commands/image/favicon.ts +89 -0
- package/src/commands/image/filters.ts +108 -0
- package/src/commands/image/index.ts +49 -0
- package/src/commands/image/resize.ts +110 -0
- package/src/commands/image/rotate.ts +90 -0
- package/src/commands/image/strip-metadata.ts +60 -0
- package/src/commands/image/to-base64.ts +72 -0
- package/src/commands/image/watermark.ts +141 -0
- package/src/commands/pdf/compress.ts +157 -0
- package/src/commands/pdf/decrypt.ts +102 -0
- package/src/commands/pdf/delete-pages.ts +112 -0
- package/src/commands/pdf/duplicate.ts +119 -0
- package/src/commands/pdf/encrypt.ts +161 -0
- package/src/commands/pdf/from-images.ts +104 -0
- package/src/commands/pdf/index.ts +55 -0
- package/src/commands/pdf/merge.ts +84 -0
- package/src/commands/pdf/ocr.ts +270 -0
- package/src/commands/pdf/organize.ts +88 -0
- package/src/commands/pdf/page-numbers.ts +152 -0
- package/src/commands/pdf/reverse.ts +71 -0
- package/src/commands/pdf/rotate.ts +116 -0
- package/src/commands/pdf/sanitize.ts +77 -0
- package/src/commands/pdf/sign.ts +156 -0
- package/src/commands/pdf/split.ts +148 -0
- package/src/commands/pdf/to-images.ts +84 -0
- package/src/commands/pdf/to-text.ts +51 -0
- package/src/commands/pdf/watermark.ts +179 -0
- package/src/commands/qr/bulk-generate.ts +136 -0
- package/src/commands/qr/generate.ts +128 -0
- package/src/commands/qr/index.ts +16 -0
- package/src/commands/qr/scan.ts +114 -0
- package/src/commands/setup.ts +156 -0
- package/src/index.ts +42 -0
- package/src/lib/audio/ffmpeg.ts +93 -0
- package/src/utils/colors.ts +41 -0
- package/src/utils/detect.ts +222 -0
- package/src/utils/errors.ts +89 -0
- package/src/utils/files.ts +148 -0
- package/src/utils/logger.ts +90 -0
- package/src/utils/pdf-tools.ts +220 -0
- package/src/utils/progress.ts +142 -0
- package/src/utils/style.ts +38 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { defineCommand } from 'citty';
|
|
5
|
+
import { checkFFmpeg } from '../../lib/audio/ffmpeg';
|
|
6
|
+
import { detectFFmpeg } from '../../utils/detect';
|
|
7
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
8
|
+
import { ensureDir, getFileSize, resolvePath } from '../../utils/files';
|
|
9
|
+
import { fileResult, info, success } from '../../utils/logger';
|
|
10
|
+
import { withSpinner } from '../../utils/progress';
|
|
11
|
+
|
|
12
|
+
export const merge = defineCommand({
|
|
13
|
+
meta: {
|
|
14
|
+
name: 'merge',
|
|
15
|
+
description: 'Merge multiple audio files into one',
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
files: {
|
|
19
|
+
type: 'positional',
|
|
20
|
+
description: 'Audio files to merge',
|
|
21
|
+
required: true,
|
|
22
|
+
},
|
|
23
|
+
output: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
alias: 'o',
|
|
26
|
+
description: 'Output file path',
|
|
27
|
+
required: true,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async run({ args }) {
|
|
31
|
+
try {
|
|
32
|
+
if (!(await checkFFmpeg())) return;
|
|
33
|
+
|
|
34
|
+
const output = requireArg(args.output, 'output');
|
|
35
|
+
const files = args._ as string[];
|
|
36
|
+
|
|
37
|
+
if (!files || files.length < 2) {
|
|
38
|
+
throw new Error('At least 2 audio files are required to merge');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Validate all files exist
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
const resolved = resolvePath(file);
|
|
44
|
+
if (!existsSync(resolved)) {
|
|
45
|
+
throw new FileNotFoundError(file);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const outputPath = resolvePath(output);
|
|
50
|
+
ensureDir(outputPath);
|
|
51
|
+
|
|
52
|
+
const ffmpeg = await detectFFmpeg();
|
|
53
|
+
if (!ffmpeg.available || !ffmpeg.path) {
|
|
54
|
+
throw new Error('FFmpeg not found');
|
|
55
|
+
}
|
|
56
|
+
const ffmpegPath = ffmpeg.path;
|
|
57
|
+
|
|
58
|
+
await withSpinner(
|
|
59
|
+
`Merging ${files.length} audio files...`,
|
|
60
|
+
async () => {
|
|
61
|
+
// Create a temporary file list for FFmpeg concat
|
|
62
|
+
const listPath = join(dirname(outputPath), `.merge-list-${Date.now()}.txt`);
|
|
63
|
+
const listContent = files.map((f) => `file '${resolvePath(f)}'`).join('\n');
|
|
64
|
+
writeFileSync(listPath, listContent);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await new Promise<void>((resolve, reject) => {
|
|
68
|
+
const proc = spawn(
|
|
69
|
+
ffmpegPath,
|
|
70
|
+
['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath],
|
|
71
|
+
{
|
|
72
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
proc.on('close', (code) => {
|
|
77
|
+
if (code === 0) resolve();
|
|
78
|
+
else reject(new Error(`FFmpeg failed with code ${code}`));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
proc.on('error', reject);
|
|
82
|
+
});
|
|
83
|
+
} finally {
|
|
84
|
+
// Clean up temp file
|
|
85
|
+
try {
|
|
86
|
+
unlinkSync(listPath);
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
'Audio files merged successfully'
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const totalInputSize = files.reduce((sum, file) => {
|
|
94
|
+
return sum + getFileSize(resolvePath(file));
|
|
95
|
+
}, 0);
|
|
96
|
+
|
|
97
|
+
info(`Merged ${files.length} files`);
|
|
98
|
+
|
|
99
|
+
fileResult(`${files.length} files`, outputPath, {
|
|
100
|
+
before: totalInputSize,
|
|
101
|
+
after: getFileSize(outputPath),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
success('Audio files merged');
|
|
105
|
+
} catch (err) {
|
|
106
|
+
handleError(err);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { checkFFmpeg, runFFmpeg } from '../../lib/audio/ffmpeg';
|
|
4
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { fileResult, info, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
export const normalize = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'normalize',
|
|
12
|
+
description: 'Normalize audio volume levels',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
input: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Input audio file',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
alias: 'o',
|
|
23
|
+
description: 'Output file path',
|
|
24
|
+
},
|
|
25
|
+
mode: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
alias: 'm',
|
|
28
|
+
description: 'Normalization mode: peak, rms, ebu (default: peak)',
|
|
29
|
+
default: 'peak',
|
|
30
|
+
},
|
|
31
|
+
target: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
alias: 't',
|
|
34
|
+
description: 'Target level in dB (default: -1 for peak, -23 for EBU R128)',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
async run({ args }) {
|
|
38
|
+
try {
|
|
39
|
+
if (!(await checkFFmpeg())) return;
|
|
40
|
+
|
|
41
|
+
const input = requireArg(args.input, 'input');
|
|
42
|
+
|
|
43
|
+
const inputPath = resolvePath(input as string);
|
|
44
|
+
if (!existsSync(inputPath)) {
|
|
45
|
+
throw new FileNotFoundError(input as string);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-normalized');
|
|
49
|
+
ensureDir(outputPath);
|
|
50
|
+
|
|
51
|
+
const inputSize = getFileSize(inputPath);
|
|
52
|
+
const mode = (args.mode || 'peak').toLowerCase();
|
|
53
|
+
|
|
54
|
+
let ffmpegArgs: string[] = [];
|
|
55
|
+
let description = '';
|
|
56
|
+
|
|
57
|
+
switch (mode) {
|
|
58
|
+
case 'peak': {
|
|
59
|
+
// Peak normalization - normalize to peak level
|
|
60
|
+
const targetPeak = args.target ? Number.parseFloat(args.target) : -1;
|
|
61
|
+
// Use loudnorm for better results, or dynaudnorm for simpler peak normalization
|
|
62
|
+
ffmpegArgs = ['-filter:a', `loudnorm=I=-16:TP=${targetPeak}:LRA=11:print_format=none`];
|
|
63
|
+
description = `Peak normalization (target: ${targetPeak}dB)`;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case 'rms': {
|
|
68
|
+
// RMS normalization using dynaudnorm
|
|
69
|
+
ffmpegArgs = ['-filter:a', 'dynaudnorm=f=150:g=15:p=0.95:m=10:r=0.9:n=1:c=1'];
|
|
70
|
+
description = 'RMS/Dynamic normalization';
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case 'ebu': {
|
|
75
|
+
// EBU R128 loudness normalization
|
|
76
|
+
const targetLoudness = args.target ? Number.parseFloat(args.target) : -23;
|
|
77
|
+
ffmpegArgs = ['-filter:a', `loudnorm=I=${targetLoudness}:TP=-1:LRA=11:print_format=none`];
|
|
78
|
+
description = `EBU R128 normalization (target: ${targetLoudness} LUFS)`;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
default:
|
|
83
|
+
throw new Error(`Unknown normalization mode: ${mode}. Use 'peak', 'rms', or 'ebu'`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await withSpinner(
|
|
87
|
+
'Normalizing audio levels...',
|
|
88
|
+
async () => {
|
|
89
|
+
await runFFmpeg({
|
|
90
|
+
input: inputPath,
|
|
91
|
+
output: outputPath,
|
|
92
|
+
args: ffmpegArgs,
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
'Audio normalized successfully'
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
info(description);
|
|
99
|
+
|
|
100
|
+
fileResult(inputPath, outputPath, {
|
|
101
|
+
before: inputSize,
|
|
102
|
+
after: getFileSize(outputPath),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
success('Audio normalized');
|
|
106
|
+
} catch (err) {
|
|
107
|
+
handleError(err);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { checkFFmpeg, runFFmpeg } from '../../lib/audio/ffmpeg';
|
|
4
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { fileResult, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
export const reverse = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'reverse',
|
|
12
|
+
description: 'Reverse audio playback',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
input: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Input audio file',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
alias: 'o',
|
|
23
|
+
description: 'Output file path',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
async run({ args }) {
|
|
27
|
+
try {
|
|
28
|
+
if (!(await checkFFmpeg())) return;
|
|
29
|
+
|
|
30
|
+
const input = requireArg(args.input, 'input');
|
|
31
|
+
|
|
32
|
+
const inputPath = resolvePath(input as string);
|
|
33
|
+
if (!existsSync(inputPath)) {
|
|
34
|
+
throw new FileNotFoundError(input as string);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-reversed');
|
|
38
|
+
ensureDir(outputPath);
|
|
39
|
+
|
|
40
|
+
const inputSize = getFileSize(inputPath);
|
|
41
|
+
|
|
42
|
+
await withSpinner(
|
|
43
|
+
'Reversing audio...',
|
|
44
|
+
async () => {
|
|
45
|
+
await runFFmpeg({
|
|
46
|
+
input: inputPath,
|
|
47
|
+
output: outputPath,
|
|
48
|
+
args: ['-filter:a', 'areverse'],
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
'Audio reversed successfully'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
fileResult(inputPath, outputPath, {
|
|
55
|
+
before: inputSize,
|
|
56
|
+
after: getFileSize(outputPath),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
success('Audio reversed');
|
|
60
|
+
} catch (err) {
|
|
61
|
+
handleError(err);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { checkFFmpeg, runFFmpeg } from '../../lib/audio/ffmpeg';
|
|
4
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { fileResult, info, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
export const speed = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'speed',
|
|
12
|
+
description: 'Change audio playback speed',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
input: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Input audio file',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
alias: 'o',
|
|
23
|
+
description: 'Output file path',
|
|
24
|
+
},
|
|
25
|
+
factor: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
alias: 'f',
|
|
28
|
+
description: 'Speed factor (0.5 = half speed, 2.0 = double speed)',
|
|
29
|
+
required: true,
|
|
30
|
+
},
|
|
31
|
+
preservePitch: {
|
|
32
|
+
type: 'boolean',
|
|
33
|
+
alias: 'p',
|
|
34
|
+
description: 'Preserve original pitch (default: true)',
|
|
35
|
+
default: true,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
async run({ args }) {
|
|
39
|
+
try {
|
|
40
|
+
if (!(await checkFFmpeg())) return;
|
|
41
|
+
|
|
42
|
+
const input = requireArg(args.input, 'input');
|
|
43
|
+
const factor = Number.parseFloat(requireArg(args.factor, 'factor'));
|
|
44
|
+
|
|
45
|
+
if (Number.isNaN(factor) || factor <= 0 || factor > 100) {
|
|
46
|
+
throw new Error('Speed factor must be between 0.01 and 100');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const inputPath = resolvePath(input as string);
|
|
50
|
+
if (!existsSync(inputPath)) {
|
|
51
|
+
throw new FileNotFoundError(input as string);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const suffix = factor > 1 ? '-fast' : '-slow';
|
|
55
|
+
const outputPath = generateOutputPath(inputPath, args.output, suffix);
|
|
56
|
+
ensureDir(outputPath);
|
|
57
|
+
|
|
58
|
+
const inputSize = getFileSize(inputPath);
|
|
59
|
+
|
|
60
|
+
// atempo filter only accepts values between 0.5 and 2.0
|
|
61
|
+
// For values outside this range, we need to chain multiple atempo filters
|
|
62
|
+
const atempoFilters: string[] = [];
|
|
63
|
+
let remaining = factor;
|
|
64
|
+
|
|
65
|
+
while (remaining > 2.0) {
|
|
66
|
+
atempoFilters.push('atempo=2.0');
|
|
67
|
+
remaining /= 2.0;
|
|
68
|
+
}
|
|
69
|
+
while (remaining < 0.5) {
|
|
70
|
+
atempoFilters.push('atempo=0.5');
|
|
71
|
+
remaining /= 0.5;
|
|
72
|
+
}
|
|
73
|
+
atempoFilters.push(`atempo=${remaining.toFixed(4)}`);
|
|
74
|
+
|
|
75
|
+
const filterStr = atempoFilters.join(',');
|
|
76
|
+
|
|
77
|
+
await withSpinner(
|
|
78
|
+
`Changing speed to ${factor}x...`,
|
|
79
|
+
async () => {
|
|
80
|
+
await runFFmpeg({
|
|
81
|
+
input: inputPath,
|
|
82
|
+
output: outputPath,
|
|
83
|
+
args: ['-filter:a', filterStr],
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
'Speed changed successfully'
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
info(`Speed factor: ${factor}x${args.preservePitch !== false ? ' (pitch preserved)' : ''}`);
|
|
90
|
+
|
|
91
|
+
fileResult(inputPath, outputPath, {
|
|
92
|
+
before: inputSize,
|
|
93
|
+
after: getFileSize(outputPath),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
success('Audio speed changed');
|
|
97
|
+
} catch (err) {
|
|
98
|
+
handleError(err);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { checkFFmpeg, parseTime, runFFmpeg } from '../../lib/audio/ffmpeg';
|
|
4
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { fileResult, info, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
export const trim = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'trim',
|
|
12
|
+
description: 'Trim an audio file',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
input: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Input audio file',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
alias: 'o',
|
|
23
|
+
description: 'Output file path',
|
|
24
|
+
},
|
|
25
|
+
start: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
alias: 's',
|
|
28
|
+
description: 'Start time (e.g., "00:30" or "30")',
|
|
29
|
+
required: true,
|
|
30
|
+
},
|
|
31
|
+
end: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
alias: 'e',
|
|
34
|
+
description: 'End time (e.g., "02:00" or "120")',
|
|
35
|
+
},
|
|
36
|
+
duration: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
alias: 'd',
|
|
39
|
+
description: 'Duration in seconds (alternative to end)',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async run({ args }) {
|
|
43
|
+
try {
|
|
44
|
+
if (!(await checkFFmpeg())) return;
|
|
45
|
+
|
|
46
|
+
const input = requireArg(args.input, 'input');
|
|
47
|
+
const start = requireArg(args.start, 'start');
|
|
48
|
+
|
|
49
|
+
const inputPath = resolvePath(input as string);
|
|
50
|
+
if (!existsSync(inputPath)) {
|
|
51
|
+
throw new FileNotFoundError(input as string);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-trimmed');
|
|
55
|
+
ensureDir(outputPath);
|
|
56
|
+
|
|
57
|
+
const inputSize = getFileSize(inputPath);
|
|
58
|
+
const startTime = parseTime(start);
|
|
59
|
+
|
|
60
|
+
const ffmpegArgs = ['-ss', String(startTime)];
|
|
61
|
+
|
|
62
|
+
if (args.end) {
|
|
63
|
+
const endTime = parseTime(args.end);
|
|
64
|
+
const duration = endTime - startTime;
|
|
65
|
+
ffmpegArgs.push('-t', String(duration));
|
|
66
|
+
} else if (args.duration) {
|
|
67
|
+
ffmpegArgs.push('-t', args.duration);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ffmpegArgs.push('-c', 'copy'); // Copy without re-encoding
|
|
71
|
+
|
|
72
|
+
await withSpinner(
|
|
73
|
+
'Trimming audio...',
|
|
74
|
+
async () => {
|
|
75
|
+
await runFFmpeg({
|
|
76
|
+
input: inputPath,
|
|
77
|
+
output: outputPath,
|
|
78
|
+
args: ffmpegArgs,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
'Audio trimmed successfully'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
info(
|
|
85
|
+
`Start: ${start}${args.end ? `, End: ${args.end}` : ''}${args.duration ? `, Duration: ${args.duration}s` : ''}`
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
fileResult(inputPath, outputPath, {
|
|
89
|
+
before: inputSize,
|
|
90
|
+
after: getFileSize(outputPath),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
success('Audio trimmed');
|
|
94
|
+
} catch (err) {
|
|
95
|
+
handleError(err);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { checkFFmpeg, runFFmpeg } from '../../lib/audio/ffmpeg';
|
|
4
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { fileResult, info, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
export const volume = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'volume',
|
|
12
|
+
description: 'Adjust audio volume',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
input: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Input audio file',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
alias: 'o',
|
|
23
|
+
description: 'Output file path',
|
|
24
|
+
},
|
|
25
|
+
level: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
alias: 'l',
|
|
28
|
+
description: 'Volume level (e.g., "1.5" for 150%, "0.5" for 50%, or "3dB", "-6dB")',
|
|
29
|
+
required: true,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
async run({ args }) {
|
|
33
|
+
try {
|
|
34
|
+
if (!(await checkFFmpeg())) return;
|
|
35
|
+
|
|
36
|
+
const input = requireArg(args.input, 'input');
|
|
37
|
+
const level = requireArg(args.level, 'level');
|
|
38
|
+
|
|
39
|
+
const inputPath = resolvePath(input as string);
|
|
40
|
+
if (!existsSync(inputPath)) {
|
|
41
|
+
throw new FileNotFoundError(input as string);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-volume');
|
|
45
|
+
ensureDir(outputPath);
|
|
46
|
+
|
|
47
|
+
const inputSize = getFileSize(inputPath);
|
|
48
|
+
|
|
49
|
+
// Parse volume level - can be a multiplier or dB value
|
|
50
|
+
let volumeArg = level;
|
|
51
|
+
let displayLevel = level;
|
|
52
|
+
|
|
53
|
+
if (level.toLowerCase().endsWith('db')) {
|
|
54
|
+
// Already in dB format
|
|
55
|
+
volumeArg = level;
|
|
56
|
+
displayLevel = level;
|
|
57
|
+
} else {
|
|
58
|
+
const numLevel = Number.parseFloat(level);
|
|
59
|
+
if (Number.isNaN(numLevel) || numLevel < 0) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'Volume level must be a positive number or dB value (e.g., "1.5" or "-6dB")'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
displayLevel = `${Math.round(numLevel * 100)}%`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await withSpinner(
|
|
68
|
+
`Adjusting volume to ${displayLevel}...`,
|
|
69
|
+
async () => {
|
|
70
|
+
await runFFmpeg({
|
|
71
|
+
input: inputPath,
|
|
72
|
+
output: outputPath,
|
|
73
|
+
args: ['-filter:a', `volume=${volumeArg}`],
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
'Volume adjusted successfully'
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
info(`Volume: ${displayLevel}`);
|
|
80
|
+
|
|
81
|
+
fileResult(inputPath, outputPath, {
|
|
82
|
+
before: inputSize,
|
|
83
|
+
after: getFileSize(outputPath),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
success('Audio volume adjusted');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
handleError(err);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
3
|
+
import { defineCommand } from 'citty';
|
|
4
|
+
import { checkFFmpeg, runFFmpeg } from '../../lib/audio/ffmpeg';
|
|
5
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
6
|
+
import { ensureDir, getFileSize, resolvePath } from '../../utils/files';
|
|
7
|
+
import { fileResult, info, success } from '../../utils/logger';
|
|
8
|
+
import { withSpinner } from '../../utils/progress';
|
|
9
|
+
|
|
10
|
+
export const waveform = defineCommand({
|
|
11
|
+
meta: {
|
|
12
|
+
name: 'waveform',
|
|
13
|
+
description: 'Generate waveform visualization image from audio',
|
|
14
|
+
},
|
|
15
|
+
args: {
|
|
16
|
+
input: {
|
|
17
|
+
type: 'positional',
|
|
18
|
+
description: 'Input audio file',
|
|
19
|
+
required: true,
|
|
20
|
+
},
|
|
21
|
+
output: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
alias: 'o',
|
|
24
|
+
description: 'Output image file (default: input-waveform.png)',
|
|
25
|
+
},
|
|
26
|
+
width: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
alias: 'w',
|
|
29
|
+
description: 'Image width in pixels (default: 1920)',
|
|
30
|
+
default: '1920',
|
|
31
|
+
},
|
|
32
|
+
height: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
alias: 'h',
|
|
35
|
+
description: 'Image height in pixels (default: 200)',
|
|
36
|
+
default: '200',
|
|
37
|
+
},
|
|
38
|
+
color: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
alias: 'c',
|
|
41
|
+
description: 'Waveform color (default: "0x00ff00" green)',
|
|
42
|
+
default: '0x00ff00',
|
|
43
|
+
},
|
|
44
|
+
background: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
alias: 'b',
|
|
47
|
+
description: 'Background color (default: "0x000000" black)',
|
|
48
|
+
default: '0x000000',
|
|
49
|
+
},
|
|
50
|
+
scale: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Amplitude scale: lin, log, sqrt, cbrt (default: lin)',
|
|
53
|
+
default: 'lin',
|
|
54
|
+
},
|
|
55
|
+
split: {
|
|
56
|
+
type: 'boolean',
|
|
57
|
+
alias: 's',
|
|
58
|
+
description: 'Split channels vertically',
|
|
59
|
+
default: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
async run({ args }) {
|
|
63
|
+
try {
|
|
64
|
+
if (!(await checkFFmpeg())) return;
|
|
65
|
+
|
|
66
|
+
const input = requireArg(args.input, 'input');
|
|
67
|
+
|
|
68
|
+
const inputPath = resolvePath(input as string);
|
|
69
|
+
if (!existsSync(inputPath)) {
|
|
70
|
+
throw new FileNotFoundError(input as string);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Generate output path for image
|
|
74
|
+
let outputPath: string;
|
|
75
|
+
if (args.output) {
|
|
76
|
+
outputPath = resolvePath(args.output);
|
|
77
|
+
} else {
|
|
78
|
+
const dir = dirname(inputPath);
|
|
79
|
+
const name = basename(inputPath, extname(inputPath));
|
|
80
|
+
outputPath = join(dir, `${name}-waveform.png`);
|
|
81
|
+
}
|
|
82
|
+
ensureDir(outputPath);
|
|
83
|
+
|
|
84
|
+
const width = Number.parseInt(args.width || '1920', 10);
|
|
85
|
+
const height = Number.parseInt(args.height || '200', 10);
|
|
86
|
+
const color = args.color || '0x00ff00';
|
|
87
|
+
const scale = args.scale || 'lin';
|
|
88
|
+
const split = args.split ? 1 : 0;
|
|
89
|
+
|
|
90
|
+
// Build showwavespic filter
|
|
91
|
+
const filter = `showwavespic=s=${width}x${height}:colors=${color}:scale=${scale}:split_channels=${split}`;
|
|
92
|
+
|
|
93
|
+
await withSpinner(
|
|
94
|
+
'Generating waveform image...',
|
|
95
|
+
async () => {
|
|
96
|
+
await runFFmpeg({
|
|
97
|
+
input: inputPath,
|
|
98
|
+
output: outputPath,
|
|
99
|
+
args: ['-filter_complex', `[0:a]${filter}[v]`, '-map', '[v]', '-frames:v', '1'],
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
'Waveform generated successfully'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
info(`Size: ${width}x${height}, Scale: ${scale}`);
|
|
106
|
+
|
|
107
|
+
fileResult(inputPath, outputPath, {
|
|
108
|
+
before: getFileSize(inputPath),
|
|
109
|
+
after: getFileSize(outputPath),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
success('Waveform image created');
|
|
113
|
+
} catch (err) {
|
|
114
|
+
handleError(err);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
});
|