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.
Files changed (74) hide show
  1. package/.github/workflows/release.yml +73 -0
  2. package/LICENSE +21 -0
  3. package/README.md +118 -0
  4. package/biome.json +34 -0
  5. package/bunfig.toml +7 -0
  6. package/dist/index.js +192 -0
  7. package/install.sh +68 -0
  8. package/package.json +47 -0
  9. package/scripts/inspect-help.ts +15 -0
  10. package/site/index.html +112 -0
  11. package/src/cli.ts +24 -0
  12. package/src/commands/audio/convert.ts +107 -0
  13. package/src/commands/audio/extract.ts +84 -0
  14. package/src/commands/audio/fade.ts +128 -0
  15. package/src/commands/audio/index.ts +35 -0
  16. package/src/commands/audio/merge.ts +109 -0
  17. package/src/commands/audio/normalize.ts +110 -0
  18. package/src/commands/audio/reverse.ts +64 -0
  19. package/src/commands/audio/speed.ts +101 -0
  20. package/src/commands/audio/trim.ts +98 -0
  21. package/src/commands/audio/volume.ts +91 -0
  22. package/src/commands/audio/waveform.ts +117 -0
  23. package/src/commands/doctor.ts +125 -0
  24. package/src/commands/image/adjust.ts +129 -0
  25. package/src/commands/image/border.ts +94 -0
  26. package/src/commands/image/bulk-compress.ts +111 -0
  27. package/src/commands/image/bulk-convert.ts +114 -0
  28. package/src/commands/image/bulk-resize.ts +112 -0
  29. package/src/commands/image/compress.ts +95 -0
  30. package/src/commands/image/convert.ts +116 -0
  31. package/src/commands/image/crop.ts +96 -0
  32. package/src/commands/image/favicon.ts +89 -0
  33. package/src/commands/image/filters.ts +108 -0
  34. package/src/commands/image/index.ts +49 -0
  35. package/src/commands/image/resize.ts +110 -0
  36. package/src/commands/image/rotate.ts +90 -0
  37. package/src/commands/image/strip-metadata.ts +60 -0
  38. package/src/commands/image/to-base64.ts +72 -0
  39. package/src/commands/image/watermark.ts +141 -0
  40. package/src/commands/pdf/compress.ts +157 -0
  41. package/src/commands/pdf/decrypt.ts +102 -0
  42. package/src/commands/pdf/delete-pages.ts +112 -0
  43. package/src/commands/pdf/duplicate.ts +119 -0
  44. package/src/commands/pdf/encrypt.ts +161 -0
  45. package/src/commands/pdf/from-images.ts +104 -0
  46. package/src/commands/pdf/index.ts +55 -0
  47. package/src/commands/pdf/merge.ts +84 -0
  48. package/src/commands/pdf/ocr.ts +270 -0
  49. package/src/commands/pdf/organize.ts +88 -0
  50. package/src/commands/pdf/page-numbers.ts +152 -0
  51. package/src/commands/pdf/reverse.ts +71 -0
  52. package/src/commands/pdf/rotate.ts +116 -0
  53. package/src/commands/pdf/sanitize.ts +77 -0
  54. package/src/commands/pdf/sign.ts +156 -0
  55. package/src/commands/pdf/split.ts +148 -0
  56. package/src/commands/pdf/to-images.ts +84 -0
  57. package/src/commands/pdf/to-text.ts +51 -0
  58. package/src/commands/pdf/watermark.ts +179 -0
  59. package/src/commands/qr/bulk-generate.ts +136 -0
  60. package/src/commands/qr/generate.ts +128 -0
  61. package/src/commands/qr/index.ts +16 -0
  62. package/src/commands/qr/scan.ts +114 -0
  63. package/src/commands/setup.ts +156 -0
  64. package/src/index.ts +42 -0
  65. package/src/lib/audio/ffmpeg.ts +93 -0
  66. package/src/utils/colors.ts +41 -0
  67. package/src/utils/detect.ts +222 -0
  68. package/src/utils/errors.ts +89 -0
  69. package/src/utils/files.ts +148 -0
  70. package/src/utils/logger.ts +90 -0
  71. package/src/utils/pdf-tools.ts +220 -0
  72. package/src/utils/progress.ts +142 -0
  73. package/src/utils/style.ts +38 -0
  74. package/tsconfig.json +27 -0
@@ -0,0 +1,136 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { defineCommand } from 'citty';
4
+ import QRCode from 'qrcode';
5
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
6
+ import { ensureDir, resolvePath } from '../../utils/files';
7
+ import { info, success, warn } from '../../utils/logger';
8
+ import { withProgressBar } from '../../utils/progress';
9
+
10
+ export const bulkGenerate = defineCommand({
11
+ meta: {
12
+ name: 'bulk-generate',
13
+ description: 'Generate multiple QR codes from a text file (one per line)',
14
+ },
15
+ args: {
16
+ input: {
17
+ type: 'positional',
18
+ description: 'Input text file with content (one item per line)',
19
+ required: true,
20
+ },
21
+ output: {
22
+ type: 'string',
23
+ alias: 'o',
24
+ description: 'Output directory (default: ./qr-codes)',
25
+ default: './qr-codes',
26
+ },
27
+ format: {
28
+ type: 'string',
29
+ alias: 'f',
30
+ description: 'Output format: png, svg (default: png)',
31
+ default: 'png',
32
+ },
33
+ size: {
34
+ type: 'string',
35
+ alias: 's',
36
+ description: 'Size/scale of the QR codes (default: 10)',
37
+ default: '10',
38
+ },
39
+ prefix: {
40
+ type: 'string',
41
+ alias: 'p',
42
+ description: 'Filename prefix (default: qr)',
43
+ default: 'qr',
44
+ },
45
+ errorLevel: {
46
+ type: 'string',
47
+ alias: 'e',
48
+ description: 'Error correction level: L, M, Q, H (default: M)',
49
+ default: 'M',
50
+ },
51
+ },
52
+ async run({ args }) {
53
+ try {
54
+ const input = requireArg(args.input, 'input');
55
+
56
+ const inputPath = resolvePath(input as string);
57
+ if (!existsSync(inputPath)) {
58
+ throw new FileNotFoundError(input as string);
59
+ }
60
+
61
+ // Read lines from input file
62
+ const content = readFileSync(inputPath, 'utf-8');
63
+ const lines = content
64
+ .split('\n')
65
+ .map((line) => line.trim())
66
+ .filter((line) => line.length > 0 && !line.startsWith('#'));
67
+
68
+ if (lines.length === 0) {
69
+ warn('No content found in input file');
70
+ return;
71
+ }
72
+
73
+ const outputDir = resolvePath(args.output || './qr-codes');
74
+ ensureDir(join(outputDir, 'dummy.txt')); // Ensure directory exists
75
+
76
+ const format = (args.format || 'png').toLowerCase();
77
+ const scale = Number.parseInt(args.size || '10', 10);
78
+ const prefix = args.prefix || 'qr';
79
+ const errorLevel = (args.errorLevel || 'M').toUpperCase() as 'L' | 'M' | 'Q' | 'H';
80
+
81
+ if (!['L', 'M', 'Q', 'H'].includes(errorLevel)) {
82
+ throw new Error('Error level must be L, M, Q, or H');
83
+ }
84
+
85
+ const options: QRCode.QRCodeToFileOptions = {
86
+ scale,
87
+ margin: 4,
88
+ errorCorrectionLevel: errorLevel,
89
+ };
90
+
91
+ info(`Generating ${lines.length} QR codes...`);
92
+
93
+ let generated = 0;
94
+ let failed = 0;
95
+
96
+ await withProgressBar(
97
+ lines,
98
+ async (line: string, index: number) => {
99
+ const filename = `${prefix}-${String(index + 1).padStart(4, '0')}.${format}`;
100
+ const outputFilePath = join(outputDir, filename);
101
+
102
+ try {
103
+ if (format === 'svg') {
104
+ const svg = await QRCode.toString(line, {
105
+ type: 'svg',
106
+ margin: 4,
107
+ errorCorrectionLevel: errorLevel,
108
+ width: scale * 25,
109
+ });
110
+ writeFileSync(outputFilePath, svg);
111
+ } else {
112
+ await QRCode.toFile(outputFilePath, line, options);
113
+ }
114
+ generated++;
115
+ } catch {
116
+ failed++;
117
+ }
118
+ },
119
+ {
120
+ label: 'Generating QR codes',
121
+ }
122
+ );
123
+
124
+ info(`Output directory: ${outputDir}`);
125
+ info(`Format: ${format.toUpperCase()}, Scale: ${scale}x, Error Level: ${errorLevel}`);
126
+
127
+ if (failed > 0) {
128
+ warn(`${failed} QR codes failed to generate`);
129
+ }
130
+
131
+ success(`${generated} QR codes generated`);
132
+ } catch (err) {
133
+ handleError(err);
134
+ }
135
+ },
136
+ });
@@ -0,0 +1,128 @@
1
+ import { basename, dirname, join } from 'node:path';
2
+ import { defineCommand } from 'citty';
3
+ import QRCode from 'qrcode';
4
+ import { handleError, requireArg } from '../../utils/errors';
5
+ import { ensureDir, getFileSize, resolvePath } from '../../utils/files';
6
+ import { info, success } from '../../utils/logger';
7
+ import { withSpinner } from '../../utils/progress';
8
+
9
+ export const generate = defineCommand({
10
+ meta: {
11
+ name: 'generate',
12
+ description: 'Generate a QR code from text or URL',
13
+ },
14
+ args: {
15
+ content: {
16
+ type: 'positional',
17
+ description: 'Text or URL to encode in QR code',
18
+ required: true,
19
+ },
20
+ output: {
21
+ type: 'string',
22
+ alias: 'o',
23
+ description: 'Output file path (default: qr.png)',
24
+ },
25
+ format: {
26
+ type: 'string',
27
+ alias: 'f',
28
+ description: 'Output format: png, svg, terminal (default: png)',
29
+ default: 'png',
30
+ },
31
+ size: {
32
+ type: 'string',
33
+ alias: 's',
34
+ description: 'Size/scale of the QR code (default: 10)',
35
+ default: '10',
36
+ },
37
+ margin: {
38
+ type: 'string',
39
+ alias: 'm',
40
+ description: 'Margin around QR code in modules (default: 4)',
41
+ default: '4',
42
+ },
43
+ errorLevel: {
44
+ type: 'string',
45
+ alias: 'e',
46
+ description: 'Error correction level: L, M, Q, H (default: M)',
47
+ default: 'M',
48
+ },
49
+ dark: {
50
+ type: 'string',
51
+ description: 'Dark module color (default: #000000)',
52
+ default: '#000000',
53
+ },
54
+ light: {
55
+ type: 'string',
56
+ description: 'Light module color (default: #ffffff)',
57
+ default: '#ffffff',
58
+ },
59
+ },
60
+ async run({ args }) {
61
+ try {
62
+ const content = requireArg(args.content, 'content') as string;
63
+ const format = (args.format || 'png').toLowerCase();
64
+ const scale = Number.parseInt(args.size || '10', 10);
65
+ const margin = Number.parseInt(args.margin || '4', 10);
66
+ const errorLevel = (args.errorLevel || 'M').toUpperCase() as 'L' | 'M' | 'Q' | 'H';
67
+
68
+ // Validate error correction level
69
+ if (!['L', 'M', 'Q', 'H'].includes(errorLevel)) {
70
+ throw new Error('Error level must be L, M, Q, or H');
71
+ }
72
+
73
+ const options: QRCode.QRCodeToFileOptions & QRCode.QRCodeToStringOptions = {
74
+ scale,
75
+ margin,
76
+ errorCorrectionLevel: errorLevel,
77
+ color: {
78
+ dark: args.dark || '#000000',
79
+ light: args.light || '#ffffff',
80
+ },
81
+ };
82
+
83
+ if (format === 'terminal') {
84
+ // Output to terminal
85
+ await withSpinner(
86
+ 'Generating QR code...',
87
+ async () => {
88
+ const qrString = await QRCode.toString(content, { type: 'terminal', ...options });
89
+ console.log(`\n${qrString}`);
90
+ },
91
+ 'QR code generated'
92
+ );
93
+ } else if (format === 'svg' || format === 'png') {
94
+ // Output to file
95
+ let outputPath: string;
96
+ if (args.output) {
97
+ outputPath = resolvePath(args.output);
98
+ } else {
99
+ outputPath = resolvePath(`qr.${format}`);
100
+ }
101
+ ensureDir(outputPath);
102
+
103
+ await withSpinner(
104
+ 'Generating QR code...',
105
+ async () => {
106
+ if (format === 'svg') {
107
+ const svg = await QRCode.toString(content, { type: 'svg', ...options });
108
+ await Bun.write(outputPath, svg);
109
+ } else {
110
+ await QRCode.toFile(outputPath, content, options);
111
+ }
112
+ },
113
+ 'QR code generated'
114
+ );
115
+
116
+ info(`Content: "${content.length > 50 ? `${content.slice(0, 47)}...` : content}"`);
117
+ info(`File: ${outputPath} (${getFileSize(outputPath)})`);
118
+ info(`Error correction: ${errorLevel}, Scale: ${scale}x`);
119
+ } else {
120
+ throw new Error(`Unknown format: ${format}. Use 'png', 'svg', or 'terminal'`);
121
+ }
122
+
123
+ success('QR code created');
124
+ } catch (err) {
125
+ handleError(err);
126
+ }
127
+ },
128
+ });
@@ -0,0 +1,16 @@
1
+ import { defineCommand } from 'citty';
2
+ import { bulkGenerate } from './bulk-generate';
3
+ import { generate } from './generate';
4
+ import { scan } from './scan';
5
+
6
+ export const qr = defineCommand({
7
+ meta: {
8
+ name: 'qr',
9
+ description: 'QR code generation and scanning tools',
10
+ },
11
+ subCommands: {
12
+ generate,
13
+ scan,
14
+ 'bulk-generate': bulkGenerate,
15
+ },
16
+ });
@@ -0,0 +1,114 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import jsQR from 'jsqr';
4
+ import sharp from 'sharp';
5
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
6
+ import { resolvePath } from '../../utils/files';
7
+ import { info, success, warn } from '../../utils/logger';
8
+ import { withSpinner } from '../../utils/progress';
9
+
10
+ interface ScanResult {
11
+ data: string;
12
+ location: {
13
+ topRightCorner: { x: number; y: number };
14
+ topLeftCorner: { x: number; y: number };
15
+ bottomRightCorner: { x: number; y: number };
16
+ bottomLeftCorner: { x: number; y: number };
17
+ };
18
+ }
19
+
20
+ export const scan = defineCommand({
21
+ meta: {
22
+ name: 'scan',
23
+ description: 'Scan and decode a QR code from an image',
24
+ },
25
+ args: {
26
+ input: {
27
+ type: 'positional',
28
+ description: 'Input image file containing QR code',
29
+ required: true,
30
+ },
31
+ json: {
32
+ type: 'boolean',
33
+ alias: 'j',
34
+ description: 'Output result as JSON',
35
+ default: false,
36
+ },
37
+ },
38
+ async run({ args }) {
39
+ try {
40
+ const input = requireArg(args.input, 'input');
41
+
42
+ const inputPath = resolvePath(input as string);
43
+ if (!existsSync(inputPath)) {
44
+ throw new FileNotFoundError(input as string);
45
+ }
46
+
47
+ const result = await withSpinner(
48
+ 'Scanning QR code...',
49
+ async (): Promise<ScanResult | null> => {
50
+ // Load image and convert to raw RGBA data
51
+ const image = sharp(inputPath);
52
+ const { width, height } = await image.metadata();
53
+
54
+ if (!width || !height) {
55
+ throw new Error('Could not read image dimensions');
56
+ }
57
+
58
+ // Convert to raw RGBA
59
+ const rawData = await image.ensureAlpha().raw().toBuffer();
60
+
61
+ // Create Uint8ClampedArray for jsQR
62
+ const data = new Uint8ClampedArray(rawData);
63
+
64
+ // Scan for QR code
65
+ const qrCode = jsQR(data, width, height);
66
+
67
+ if (qrCode) {
68
+ return {
69
+ data: qrCode.data,
70
+ location: qrCode.location,
71
+ };
72
+ }
73
+ return null;
74
+ },
75
+ 'Scan complete'
76
+ );
77
+
78
+ if (result) {
79
+ if (args.json) {
80
+ console.log(JSON.stringify(result, null, 2));
81
+ } else {
82
+ info(`Content: ${result.data}`);
83
+
84
+ // Detect content type
85
+ const contentData = result.data;
86
+ if (contentData.startsWith('http://') || contentData.startsWith('https://')) {
87
+ info('Type: URL');
88
+ } else if (contentData.startsWith('mailto:')) {
89
+ info('Type: Email');
90
+ } else if (contentData.startsWith('tel:')) {
91
+ info('Type: Phone');
92
+ } else if (contentData.startsWith('WIFI:')) {
93
+ info('Type: WiFi Network');
94
+ } else if (contentData.startsWith('BEGIN:VCARD')) {
95
+ info('Type: Contact (vCard)');
96
+ } else if (contentData.startsWith('BEGIN:VEVENT')) {
97
+ info('Type: Calendar Event');
98
+ } else {
99
+ info('Type: Text');
100
+ }
101
+ }
102
+ success('QR code decoded');
103
+ } else {
104
+ warn('No QR code found in the image');
105
+ info('Tips:');
106
+ info(' - Ensure the QR code is clearly visible');
107
+ info(' - Try cropping the image to focus on the QR code');
108
+ info(' - Ensure good contrast and lighting');
109
+ }
110
+ } catch (err) {
111
+ handleError(err);
112
+ }
113
+ },
114
+ });
@@ -0,0 +1,156 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ import { defineCommand } from 'citty';
4
+ import { type ToolInfo, detectTool, getPackageMap } from '../utils/detect';
5
+ import { error, info, success, warn } from '../utils/logger';
6
+ import { c, sym } from '../utils/style';
7
+
8
+ const TOOLS = [
9
+ { name: 'ffmpeg', check: 'ffmpeg' },
10
+ { name: 'ghostscript', check: 'gs', altCheck: 'gswin64c' },
11
+ { name: 'qpdf', check: 'qpdf' },
12
+ { name: 'mupdf', check: 'mutool' },
13
+ { name: 'poppler', check: 'pdftotext' },
14
+ { name: 'imagemagick', check: 'magick', altCheck: 'convert' },
15
+ ];
16
+
17
+ async function detectPackageManager(): Promise<
18
+ 'brew' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'winget' | null
19
+ > {
20
+ if (process.platform === 'darwin') {
21
+ if ((await detectTool('brew')).available) return 'brew';
22
+ } else if (process.platform === 'win32') {
23
+ if ((await detectTool('winget')).available) return 'winget';
24
+ } else if (process.platform === 'linux') {
25
+ if ((await detectTool('apt-get')).available) return 'apt';
26
+ if ((await detectTool('dnf')).available) return 'dnf';
27
+ if ((await detectTool('pacman')).available) return 'pacman';
28
+ if ((await detectTool('apk')).available) return 'apk';
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function askConfirmation(question: string): Promise<boolean> {
34
+ const rl = createInterface({
35
+ input: process.stdin,
36
+ output: process.stdout,
37
+ });
38
+
39
+ return new Promise((resolve) => {
40
+ rl.question(question, (answer) => {
41
+ rl.close();
42
+ resolve(answer.toLowerCase().startsWith('y'));
43
+ });
44
+ });
45
+ }
46
+
47
+ export const setup = defineCommand({
48
+ meta: {
49
+ name: 'setup',
50
+ description: 'Automatically install missing system dependencies',
51
+ },
52
+ args: {
53
+ yes: {
54
+ type: 'boolean',
55
+ alias: 'y',
56
+ description: 'Skip confirmation',
57
+ },
58
+ },
59
+ async run({ args }) {
60
+ console.log('');
61
+ info('NoUpload CLI Setup');
62
+ console.log(c.dim(' Checking environment...'));
63
+
64
+ // 1. Detect Package Manager
65
+ const pm = await detectPackageManager();
66
+ if (!pm) {
67
+ error('Could not detect a supported package manager (brew, apt, winget).');
68
+ console.log(
69
+ ` ${c.dim('Hint:')} Please install dependencies manually using "noupload doctor".`
70
+ );
71
+ return;
72
+ }
73
+ console.log(` ${c.dim('Package Manager:')} ${c.active(pm)}`);
74
+
75
+ // 2. Detect Missing Tools
76
+ const missing: string[] = [];
77
+ const pkgMap = getPackageMap();
78
+
79
+ for (const tool of TOOLS) {
80
+ let available = (await detectTool(tool.check)).available;
81
+ if (!available && tool.altCheck) {
82
+ available = (await detectTool(tool.altCheck)).available;
83
+ }
84
+
85
+ if (!available) {
86
+ missing.push(tool.name);
87
+ }
88
+ }
89
+
90
+ if (missing.length === 0) {
91
+ success('All dependencies are already installed!');
92
+ return;
93
+ }
94
+
95
+ // 3. Build Install Command
96
+ const packages = missing.map((tool) => pkgMap[tool][pm]);
97
+ let cmd = '';
98
+ const cmdArgs: string[] = [];
99
+
100
+ if (pm === 'brew') {
101
+ cmd = 'brew';
102
+ cmdArgs.push('install', ...packages);
103
+ } else if (pm === 'apt') {
104
+ cmd = 'sudo';
105
+ cmdArgs.push('apt-get', 'install', '-y', ...packages);
106
+ } else if (pm === 'dnf') {
107
+ cmd = 'sudo';
108
+ cmdArgs.push('dnf', 'install', '-y', ...packages);
109
+ } else if (pm === 'pacman') {
110
+ cmd = 'sudo';
111
+ cmdArgs.push('pacman', '-S', '--noconfirm', ...packages);
112
+ } else if (pm === 'apk') {
113
+ cmd = 'sudo';
114
+ cmdArgs.push('apk', 'add', ...packages);
115
+ } else if (pm === 'winget') {
116
+ cmd = 'winget';
117
+ cmdArgs.push('install', ...packages);
118
+ }
119
+
120
+ const fullCmd = `${cmd} ${cmdArgs.join(' ')}`;
121
+
122
+ console.log('');
123
+ console.warn(`${c.warn(sym.warn)} ${c.warn(`Missing tools: ${missing.join(', ')}`)}`);
124
+ console.log(` ${c.dim('Command:')} ${c.white(fullCmd)}`);
125
+ console.log('');
126
+
127
+ // 4. Confirm and Execute
128
+ if (!args.yes) {
129
+ const q = `${c.active(sym.input)} Run this command now? [y/N] `;
130
+ const confirmed = await askConfirmation(q);
131
+ if (!confirmed) {
132
+ console.log(c.dim(' Setup cancelled.'));
133
+ return;
134
+ }
135
+ }
136
+
137
+ console.log('');
138
+ console.log(c.dim(' Running installer... (you may be asked for your password)'));
139
+ console.log('');
140
+
141
+ const child = spawn(cmd, cmdArgs, {
142
+ stdio: 'inherit', // Important: Inherit stdio so user can interact/see progress
143
+ shell: true,
144
+ });
145
+
146
+ child.on('close', (code) => {
147
+ console.log('');
148
+ if (code === 0) {
149
+ success('Setup complete! All dependencies installed.');
150
+ } else {
151
+ error(`Installer failed with code ${code}.`);
152
+ console.log(c.dim(' Please try running the command manually.'));
153
+ }
154
+ });
155
+ },
156
+ });
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bun
2
+ import { renderUsage, runMain } from 'citty';
3
+ import { main } from './cli';
4
+ import { hex } from './utils/colors';
5
+
6
+ // Theme Colors
7
+ const ORANGE = hex('#f59e0b');
8
+ const PURPLE = hex('#a855f7'); // Tailwind Purple 500
9
+
10
+ // biome-ignore lint/suspicious/noExplicitAny: generic wrapper for citty types
11
+ async function customShowUsage(cmd: any, parent?: any) {
12
+ try {
13
+ let output = await renderUsage(cmd, parent);
14
+
15
+ // Fix ugly subcommand lists in USAGE line
16
+ // Replaces long lists like "merge|split|compress|..." with "<command>"
17
+ output = output.replace(/ ([a-z0-9-]+\|[a-z0-9-|]+)/g, ' <command>');
18
+
19
+ // Colorize commands in the list to Theme Orange
20
+ output = output.replace(/^\s+`([a-z0-9-]+)`/gm, (match, commandName) => {
21
+ const indentation = match.substring(0, match.indexOf('`'));
22
+ return `${indentation}${ORANGE(commandName)}`;
23
+ });
24
+
25
+ // Colorize footer example command to Theme Purple
26
+ output = output.replace(/Use `([^`]+)`/g, (match, content) => {
27
+ return `Use ${PURPLE(content)}`;
28
+ });
29
+
30
+ // Colorize remaining backticked items (Usage line) to Theme Orange
31
+ output = output.replace(/`([^`]+)`/g, (match, content) => {
32
+ // Backticks are already stripped from 'content' capture group
33
+ return ORANGE(content);
34
+ });
35
+
36
+ console.log(`${output}\n`);
37
+ } catch (error) {
38
+ console.error(error);
39
+ }
40
+ }
41
+
42
+ runMain(main, { showUsage: customShowUsage });
@@ -0,0 +1,93 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { detectFFmpeg, getInstallCommand } from '../../utils/detect';
3
+ import { warn } from '../../utils/logger';
4
+ import { c } from '../../utils/style';
5
+
6
+ export interface FFmpegOptions {
7
+ input: string;
8
+ output: string;
9
+ args?: string[];
10
+ }
11
+
12
+ // Run FFmpeg command
13
+ export async function runFFmpeg(options: FFmpegOptions): Promise<void> {
14
+ const ffmpeg = await detectFFmpeg();
15
+
16
+ if (!ffmpeg.available || !ffmpeg.path) {
17
+ const installCmd = getInstallCommand('ffmpeg');
18
+ throw new Error(`FFmpeg not found. ${installCmd ? `Install with: ${installCmd}` : ''}`);
19
+ }
20
+
21
+ const ffmpegPath = ffmpeg.path;
22
+
23
+ const args = [
24
+ '-y', // Overwrite output
25
+ '-i',
26
+ options.input,
27
+ ...(options.args || []),
28
+ options.output,
29
+ ];
30
+
31
+ return new Promise((resolve, reject) => {
32
+ const proc = spawn(ffmpegPath, args, {
33
+ stdio: ['pipe', 'pipe', 'pipe'],
34
+ });
35
+
36
+ let stderr = '';
37
+ proc.stderr?.on('data', (data) => {
38
+ stderr += data.toString();
39
+ });
40
+
41
+ proc.on('close', (code) => {
42
+ if (code === 0) {
43
+ resolve();
44
+ } else {
45
+ reject(new Error(`FFmpeg failed: ${stderr.slice(-500)}`));
46
+ }
47
+ });
48
+
49
+ proc.on('error', reject);
50
+ });
51
+ }
52
+
53
+ // Check if FFmpeg is available
54
+ export async function checkFFmpeg(): Promise<boolean> {
55
+ const ffmpeg = await detectFFmpeg();
56
+
57
+ if (!ffmpeg.available) {
58
+ warn('FFmpeg is required for audio operations');
59
+ const installCmd = getInstallCommand('ffmpeg');
60
+ if (installCmd) {
61
+ console.log(` ${c.active('Install:')} ${installCmd}`);
62
+ }
63
+ return false;
64
+ }
65
+
66
+ return true;
67
+ }
68
+
69
+ // Parse time string to seconds (supports "HH:MM:SS" or "MM:SS" or seconds)
70
+ export function parseTime(time: string): number {
71
+ if (time.includes(':')) {
72
+ const parts = time.split(':').map(Number);
73
+ if (parts.length === 3) {
74
+ return parts[0] * 3600 + parts[1] * 60 + parts[2];
75
+ }
76
+ if (parts.length === 2) {
77
+ return parts[0] * 60 + parts[1];
78
+ }
79
+ }
80
+ return Number.parseFloat(time);
81
+ }
82
+
83
+ // Format seconds to time string
84
+ export function formatTime(seconds: number): string {
85
+ const h = Math.floor(seconds / 3600);
86
+ const m = Math.floor((seconds % 3600) / 60);
87
+ const s = Math.floor(seconds % 60);
88
+
89
+ if (h > 0) {
90
+ return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
91
+ }
92
+ return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
93
+ }