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
package/install.sh ADDED
@@ -0,0 +1,68 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Configuration
5
+ BINARY_NAME="noupload"
6
+ INSTALL_DIR="/usr/local/bin"
7
+
8
+ # Detect OS and Arch
9
+ OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
10
+ ARCH="$(uname -m)"
11
+
12
+ # Map architecture names
13
+ if [ "$ARCH" == "x86_64" ]; then
14
+ ARCH="x64"
15
+ elif [ "$ARCH" == "aarch64" ] || [ "$ARCH" == "arm64" ]; then
16
+ ARCH="arm64"
17
+ else
18
+ echo "❌ Unsupported architecture: $ARCH"
19
+ exit 1
20
+ fi
21
+
22
+ # Construct asset name (e.g., noupload-linux-x64)
23
+ ASSET_NAME="${BINARY_NAME}-${OS}-${ARCH}"
24
+ if [ "$OS" == "windows" ]; then
25
+ ASSET_NAME="${ASSET_NAME}.exe"
26
+ fi
27
+
28
+ echo "⬇️ Installing ${BINARY_NAME} for ${OS}/${ARCH}..."
29
+
30
+ # Find latest release URL
31
+ LATEST_URL="https://noupload.xyz/releases/${ASSET_NAME}"
32
+
33
+ # Setup install directory
34
+ if [ ! -d "$INSTALL_DIR" ]; then
35
+ # Fallback to local bin if /usr/local/bin fails or doesn't exist
36
+ INSTALL_DIR="$HOME/.local/bin"
37
+ mkdir -p "$INSTALL_DIR"
38
+
39
+ # Add to PATH if not present
40
+ if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
41
+ echo "export PATH=\"\$PATH:$INSTALL_DIR\"" >> "$HOME/.bashrc"
42
+ echo "export PATH=\"\$PATH:$INSTALL_DIR\"" >> "$HOME/.zshrc"
43
+ fi
44
+ fi
45
+
46
+ # Download
47
+ TEMP_FILE="/tmp/${BINARY_NAME}"
48
+ if command -v curl >/dev/null 2>&1; then
49
+ curl -fsSL "$LATEST_URL" -o "$TEMP_FILE"
50
+ elif command -v wget >/dev/null 2>&1; then
51
+ wget -qO "$TEMP_FILE" "$LATEST_URL"
52
+ else
53
+ echo "❌ Error: Need curl or wget to download."
54
+ exit 1
55
+ fi
56
+
57
+ # Install
58
+ chmod +x "$TEMP_FILE"
59
+ echo "📦 Moving to $INSTALL_DIR..."
60
+
61
+ if [ -w "$INSTALL_DIR" ]; then
62
+ mv "$TEMP_FILE" "$INSTALL_DIR/$BINARY_NAME"
63
+ else
64
+ sudo mv "$TEMP_FILE" "$INSTALL_DIR/$BINARY_NAME"
65
+ fi
66
+
67
+ echo "✅ Installed successfully!"
68
+ echo "Run 'noupload setup' to install system dependencies."
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "noupload",
3
+ "version": "1.0.0",
4
+ "description": "Privacy-first CLI for PDF, Image, Audio, and QR operations - all processed locally",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "noupload": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "dev": "bun run src/index.ts",
12
+ "build": "bun build src/index.ts --outdir dist --target bun --minify",
13
+ "build:compile": "bun build src/index.ts --compile --outfile dist/noupload",
14
+ "build:all": "bun run scripts/build.ts",
15
+ "test": "bun test",
16
+ "lint": "bunx @biomejs/biome check .",
17
+ "format": "bunx @biomejs/biome format --write .",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "citty": "^0.1.6",
22
+ "cli-progress": "^3.12.0",
23
+ "consola": "^3.2.3",
24
+ "filesize": "^10.1.6",
25
+ "fluent-ffmpeg": "^2.1.3",
26
+ "jsqr": "^1.4.0",
27
+ "ora": "^8.1.1",
28
+ "pdf-lib": "^1.17.1",
29
+ "qrcode": "^1.5.4",
30
+ "sharp": "^0.33.5",
31
+ "tesseract.js": "^5.1.1"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "latest",
35
+ "@types/cli-progress": "^3.11.6",
36
+ "@types/fluent-ffmpeg": "^2.1.27",
37
+ "@types/qrcode": "^1.5.5",
38
+ "@types/which": "^3.0.4",
39
+ "@biomejs/biome": "^1.9.4",
40
+ "typescript": "^5.7.2"
41
+ },
42
+ "engines": {
43
+ "bun": ">=1.0.0"
44
+ },
45
+ "license": "MIT",
46
+ "keywords": ["pdf", "image", "audio", "qr", "cli", "privacy", "offline", "compress", "convert", "ocr"]
47
+ }
@@ -0,0 +1,15 @@
1
+ import { renderUsage, runMain } from 'citty';
2
+ import { main } from '../src/cli';
3
+
4
+ async function inspectHelp() {
5
+ // Capture the output
6
+ const output = await renderUsage(main);
7
+ console.log('--- RAW OUTPUT START ---');
8
+ console.log(JSON.stringify(output));
9
+ // Print hex of the last line (likely containing the footer)
10
+ const lastLine = output.split('\n').pop() || '';
11
+ console.log('Footer Hex:', Buffer.from(lastLine).toString('hex'));
12
+ console.log('--- RAW OUTPUT END ---');
13
+ }
14
+
15
+ inspectHelp();
@@ -0,0 +1,112 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>NoUpload CLI - Privacy-First Media Tools</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;600;800&display=swap');
10
+ body { font-family: 'Inter', sans-serif; }
11
+ .mono { font-family: 'JetBrains Mono', monospace; }
12
+
13
+ /* Orange/Purple Gradient Animation */
14
+ .text-gradient {
15
+ background: linear-gradient(to right, #f59e0b, #a855f7);
16
+ -webkit-background-clip: text;
17
+ -webkit-text-fill-color: transparent;
18
+ }
19
+
20
+ .blob {
21
+ position: absolute;
22
+ filter: blur(80px);
23
+ z-index: -1;
24
+ opacity: 0.4;
25
+ }
26
+ </style>
27
+ </head>
28
+ <body class="bg-gray-950 text-white min-h-screen flex flex-col items-center relative overflow-hidden selection:bg-orange-500/30">
29
+
30
+ <!-- Background Glows -->
31
+ <div class="blob bg-orange-600 w-96 h-96 rounded-full top-0 left-0 -translate-x-1/2 -translate-y-1/2"></div>
32
+ <div class="blob bg-purple-600 w-96 h-96 rounded-full bottom-0 right-0 translate-x-1/2 translate-y-1/2"></div>
33
+
34
+ <!-- Nav -->
35
+ <nav class="w-full max-w-6xl mx-auto p-6 flex justify-between items-center">
36
+ <div class="font-bold text-xl tracking-tight flex items-center gap-2">
37
+ <span class="text-2xl">🛡️</span> noupload.xyz
38
+ </div>
39
+ <a href="https://github.com/shockz09/nouploadcli" class="text-gray-400 hover:text-white transition-colors">GitHub ↗</a>
40
+ </nav>
41
+
42
+ <!-- Hero -->
43
+ <main class="flex-1 flex flex-col items-center justify-center text-center px-4 max-w-4xl mx-auto w-full mt-10 mb-20">
44
+
45
+ <div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/5 border border-white/10 text-sm text-gray-300 mb-8 backdrop-blur-sm">
46
+ <span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
47
+ v1.0.0 Released
48
+ </div>
49
+
50
+ <h1 class="text-5xl md:text-7xl font-extrabold tracking-tight mb-6">
51
+ Media tools that <br>
52
+ <span class="text-gradient">respect your privacy.</span>
53
+ </h1>
54
+
55
+ <p class="text-xl text-gray-400 max-w-2xl mb-10 leading-relaxed">
56
+ Merge PDFs, compress images, and trim audio directly on your device.
57
+ <span class="text-gray-200 font-semibold">Zero uploads. Zero clouds. Zero logs.</span>
58
+ </p>
59
+
60
+ <!-- Install Box -->
61
+ <div class="w-full max-w-2xl bg-[#0f1117] border border-gray-800 rounded-xl p-4 md:p-6 shadow-2xl relative group">
62
+ <div class="flex items-center justify-between gap-4">
63
+ <div class="flex items-center gap-3 overflow-x-auto no-scrollbar">
64
+ <span class="text-green-500 font-bold select-none">$</span>
65
+ <code class="mono text-gray-200 whitespace-nowrap text-sm md:text-base">curl https://noupload.xyz/install | bash</code>
66
+ </div>
67
+ <button onclick="copyCmd()" class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white" title="Copy to clipboard">
68
+ <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
69
+ </button>
70
+ </div>
71
+
72
+ <!-- Glow effect on hover -->
73
+ <div class="absolute -inset-0.5 bg-gradient-to-r from-orange-500 to-purple-600 rounded-xl opacity-0 group-hover:opacity-30 transition-opacity blur duration-500 -z-10"></div>
74
+ </div>
75
+
76
+ <!-- Features Grid -->
77
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-20 w-full">
78
+ <div class="p-6 rounded-2xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors text-left">
79
+ <div class="text-3xl mb-4">📄</div>
80
+ <h3 class="font-bold text-lg mb-2 text-orange-400">PDF Tools</h3>
81
+ <p class="text-gray-400 text-sm">Merge, Split, Encrypt, OCR, Watermark, and Compress PDFs efficiently.</p>
82
+ </div>
83
+ <div class="p-6 rounded-2xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors text-left">
84
+ <div class="text-3xl mb-4">🖼️</div>
85
+ <h3 class="font-bold text-lg mb-2 text-purple-400">Image Magic</h3>
86
+ <p class="text-gray-400 text-sm">Resize, Convert (WebP/AVIF), Filter, Crop, and strip metadata in bulk.</p>
87
+ </div>
88
+ <div class="p-6 rounded-2xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors text-left">
89
+ <div class="text-3xl mb-4">🎵</div>
90
+ <h3 class="font-bold text-lg mb-2 text-blue-400">Audio Suite</h3>
91
+ <p class="text-gray-400 text-sm">Trim, Speed up, Normalize, Convert formats, and generate waveforms.</p>
92
+ </div>
93
+ </div>
94
+
95
+ </main>
96
+
97
+ <footer class="w-full border-t border-white/5 p-8 text-center text-gray-600 text-sm">
98
+ <p>Open Source. MIT License. Created by <a href="https://github.com/shockz09" class="text-gray-400 hover:text-white">shockz09</a>.</p>
99
+ </footer>
100
+
101
+ <script>
102
+ function copyCmd() {
103
+ navigator.clipboard.writeText('curl https://noupload.xyz/install | bash');
104
+ const btn = document.querySelector('button');
105
+ btn.innerHTML = '<span class="text-green-500 text-sm font-bold">Copied!</span>';
106
+ setTimeout(() => {
107
+ btn.innerHTML = '<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>';
108
+ }, 2000);
109
+ }
110
+ </script>
111
+ </body>
112
+ </html>
package/src/cli.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { defineCommand } from 'citty';
2
+ import { audio } from './commands/audio';
3
+ import { doctor } from './commands/doctor';
4
+ import { image } from './commands/image';
5
+ import { pdf } from './commands/pdf';
6
+ import { qr } from './commands/qr';
7
+ import { setup } from './commands/setup';
8
+
9
+ export const main = defineCommand({
10
+ meta: {
11
+ name: 'noupload',
12
+ version: '1.0.0',
13
+ description:
14
+ 'Privacy-first CLI for PDF, Image, Audio, and QR operations - all processed locally',
15
+ },
16
+ subCommands: {
17
+ pdf,
18
+ image,
19
+ audio,
20
+ qr,
21
+ doctor,
22
+ setup,
23
+ },
24
+ });
@@ -0,0 +1,107 @@
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 {
6
+ ensureDir,
7
+ generateOutputPath,
8
+ getExtension,
9
+ getFileSize,
10
+ resolvePath,
11
+ } from '../../utils/files';
12
+ import { fileResult, info, success } from '../../utils/logger';
13
+ import { withSpinner } from '../../utils/progress';
14
+
15
+ const FORMAT_CODECS: Record<string, string[]> = {
16
+ mp3: ['-c:a', 'libmp3lame', '-q:a', '2'],
17
+ wav: ['-c:a', 'pcm_s16le'],
18
+ ogg: ['-c:a', 'libvorbis', '-q:a', '4'],
19
+ flac: ['-c:a', 'flac'],
20
+ m4a: ['-c:a', 'aac', '-q:a', '1'],
21
+ aac: ['-c:a', 'aac', '-q:a', '1'],
22
+ opus: ['-c:a', 'libopus', '-b:a', '128k'],
23
+ };
24
+
25
+ export const convert = defineCommand({
26
+ meta: {
27
+ name: 'convert',
28
+ description: 'Convert an audio file to a different format',
29
+ },
30
+ args: {
31
+ input: {
32
+ type: 'positional',
33
+ description: 'Input audio file',
34
+ required: true,
35
+ },
36
+ output: {
37
+ type: 'string',
38
+ alias: 'o',
39
+ description: 'Output file path',
40
+ },
41
+ format: {
42
+ type: 'string',
43
+ alias: 'f',
44
+ description: `Output format: ${Object.keys(FORMAT_CODECS).join(', ')}`,
45
+ required: true,
46
+ },
47
+ bitrate: {
48
+ type: 'string',
49
+ alias: 'b',
50
+ description: 'Audio bitrate (e.g., "192k", "320k")',
51
+ },
52
+ },
53
+ async run({ args }) {
54
+ try {
55
+ if (!(await checkFFmpeg())) return;
56
+
57
+ const input = requireArg(args.input, 'input');
58
+ const format = requireArg(args.format, 'format').toLowerCase();
59
+
60
+ if (!FORMAT_CODECS[format]) {
61
+ throw new Error(
62
+ `Unsupported format: ${format}. Supported: ${Object.keys(FORMAT_CODECS).join(', ')}`
63
+ );
64
+ }
65
+
66
+ const inputPath = resolvePath(input as string);
67
+ if (!existsSync(inputPath)) {
68
+ throw new FileNotFoundError(input as string);
69
+ }
70
+
71
+ const inputExt = getExtension(inputPath);
72
+ const outputPath = generateOutputPath(inputPath, args.output, '', format);
73
+ ensureDir(outputPath);
74
+
75
+ const inputSize = getFileSize(inputPath);
76
+
77
+ const ffmpegArgs = [...FORMAT_CODECS[format]];
78
+
79
+ if (args.bitrate) {
80
+ ffmpegArgs.push('-b:a', args.bitrate);
81
+ }
82
+
83
+ await withSpinner(
84
+ `Converting ${inputExt.toUpperCase()} to ${format.toUpperCase()}...`,
85
+ async () => {
86
+ await runFFmpeg({
87
+ input: inputPath,
88
+ output: outputPath,
89
+ args: ffmpegArgs,
90
+ });
91
+ },
92
+ 'Audio converted successfully'
93
+ );
94
+
95
+ info(`Format: ${inputExt.toUpperCase()} → ${format.toUpperCase()}`);
96
+
97
+ fileResult(inputPath, outputPath, {
98
+ before: inputSize,
99
+ after: getFileSize(outputPath),
100
+ });
101
+
102
+ success('Audio converted');
103
+ } catch (err) {
104
+ handleError(err);
105
+ }
106
+ },
107
+ });
@@ -0,0 +1,84 @@
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 extract = defineCommand({
10
+ meta: {
11
+ name: 'extract',
12
+ description: 'Extract audio from a video file',
13
+ },
14
+ args: {
15
+ input: {
16
+ type: 'positional',
17
+ description: 'Input video file',
18
+ required: true,
19
+ },
20
+ output: {
21
+ type: 'string',
22
+ alias: 'o',
23
+ description: 'Output audio file',
24
+ },
25
+ format: {
26
+ type: 'string',
27
+ alias: 'f',
28
+ description: 'Output format (mp3, wav, m4a)',
29
+ default: 'mp3',
30
+ },
31
+ },
32
+ async run({ args }) {
33
+ try {
34
+ if (!(await checkFFmpeg())) return;
35
+
36
+ const input = requireArg(args.input, 'input');
37
+
38
+ const inputPath = resolvePath(input as string);
39
+ if (!existsSync(inputPath)) {
40
+ throw new FileNotFoundError(input as string);
41
+ }
42
+
43
+ const format = args.format || 'mp3';
44
+ const outputPath = generateOutputPath(inputPath, args.output, '-audio', format);
45
+ ensureDir(outputPath);
46
+
47
+ const inputSize = getFileSize(inputPath);
48
+
49
+ const codecMap: Record<string, string[]> = {
50
+ mp3: ['-c:a', 'libmp3lame', '-q:a', '2'],
51
+ wav: ['-c:a', 'pcm_s16le'],
52
+ m4a: ['-c:a', 'aac', '-q:a', '1'],
53
+ };
54
+
55
+ const ffmpegArgs = [
56
+ '-vn', // No video
57
+ ...(codecMap[format] || codecMap.mp3),
58
+ ];
59
+
60
+ await withSpinner(
61
+ 'Extracting audio from video...',
62
+ async () => {
63
+ await runFFmpeg({
64
+ input: inputPath,
65
+ output: outputPath,
66
+ args: ffmpegArgs,
67
+ });
68
+ },
69
+ 'Audio extracted successfully'
70
+ );
71
+
72
+ info(`Extracted as ${format.toUpperCase()}`);
73
+
74
+ fileResult(inputPath, outputPath, {
75
+ before: inputSize,
76
+ after: getFileSize(outputPath),
77
+ });
78
+
79
+ success('Audio extracted from video');
80
+ } catch (err) {
81
+ handleError(err);
82
+ }
83
+ },
84
+ });
@@ -0,0 +1,128 @@
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 fade = defineCommand({
10
+ meta: {
11
+ name: 'fade',
12
+ description: 'Add fade in/out effects to audio',
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
+ fadeIn: {
26
+ type: 'string',
27
+ alias: 'i',
28
+ description: 'Fade in duration in seconds (e.g., "3" or "0:03")',
29
+ },
30
+ fadeOut: {
31
+ type: 'string',
32
+ alias: 'u',
33
+ description: 'Fade out duration in seconds (e.g., "3" or "0:03")',
34
+ },
35
+ type: {
36
+ type: 'string',
37
+ alias: 't',
38
+ description:
39
+ 'Fade curve type: tri, qsin, hsin, esin, log, ipar, qua, cub, squ, cbr, par, exp, iqsin, ihsin, dese, desi, losi, sinc, isinc, nofade (default: tri)',
40
+ default: 'tri',
41
+ },
42
+ },
43
+ async run({ args }) {
44
+ try {
45
+ if (!(await checkFFmpeg())) return;
46
+
47
+ const input = requireArg(args.input, 'input');
48
+
49
+ if (!args.fadeIn && !args.fadeOut) {
50
+ throw new Error('At least one of --fade-in or --fade-out must be specified');
51
+ }
52
+
53
+ const inputPath = resolvePath(input as string);
54
+ if (!existsSync(inputPath)) {
55
+ throw new FileNotFoundError(input as string);
56
+ }
57
+
58
+ const outputPath = generateOutputPath(inputPath, args.output, '-fade');
59
+ ensureDir(outputPath);
60
+
61
+ const inputSize = getFileSize(inputPath);
62
+ const curveType = args.type || 'tri';
63
+
64
+ const filters: string[] = [];
65
+
66
+ if (args.fadeIn) {
67
+ const fadeInDuration = parseTime(args.fadeIn);
68
+ filters.push(`afade=t=in:st=0:d=${fadeInDuration}:curve=${curveType}`);
69
+ }
70
+
71
+ if (args.fadeOut) {
72
+ const fadeOutDuration = parseTime(args.fadeOut);
73
+ // For fade out, we need to use 'areverse,afade,areverse' trick or specify start time
74
+ // Using negative start time with 'st' makes it relative to end
75
+ filters.push(`afade=t=out:st=-${fadeOutDuration}:d=${fadeOutDuration}:curve=${curveType}`);
76
+ }
77
+
78
+ // Use the complex filter for fade out at end
79
+ const ffmpegArgs: string[] = [];
80
+
81
+ if (args.fadeIn && args.fadeOut) {
82
+ const fadeInDuration = parseTime(args.fadeIn);
83
+ const fadeOutDuration = parseTime(args.fadeOut);
84
+ // Chain both fades - fade out needs to be applied after getting duration
85
+ ffmpegArgs.push(
86
+ '-filter_complex',
87
+ `afade=t=in:st=0:d=${fadeInDuration}:curve=${curveType},areverse,afade=t=in:st=0:d=${fadeOutDuration}:curve=${curveType},areverse`
88
+ );
89
+ } else if (args.fadeIn) {
90
+ const fadeInDuration = parseTime(args.fadeIn);
91
+ ffmpegArgs.push('-filter:a', `afade=t=in:st=0:d=${fadeInDuration}:curve=${curveType}`);
92
+ } else if (args.fadeOut) {
93
+ const fadeOutDuration = parseTime(args.fadeOut);
94
+ // Use areverse trick to apply fade from end
95
+ ffmpegArgs.push(
96
+ '-filter_complex',
97
+ `areverse,afade=t=in:st=0:d=${fadeOutDuration}:curve=${curveType},areverse`
98
+ );
99
+ }
100
+
101
+ await withSpinner(
102
+ 'Adding fade effects...',
103
+ async () => {
104
+ await runFFmpeg({
105
+ input: inputPath,
106
+ output: outputPath,
107
+ args: ffmpegArgs,
108
+ });
109
+ },
110
+ 'Fade effects added successfully'
111
+ );
112
+
113
+ const effects: string[] = [];
114
+ if (args.fadeIn) effects.push(`Fade in: ${args.fadeIn}s`);
115
+ if (args.fadeOut) effects.push(`Fade out: ${args.fadeOut}s`);
116
+ info(`${effects.join(', ')} (curve: ${curveType})`);
117
+
118
+ fileResult(inputPath, outputPath, {
119
+ before: inputSize,
120
+ after: getFileSize(outputPath),
121
+ });
122
+
123
+ success('Fade effects applied');
124
+ } catch (err) {
125
+ handleError(err);
126
+ }
127
+ },
128
+ });
@@ -0,0 +1,35 @@
1
+ import { defineCommand } from 'citty';
2
+ import { convert } from './convert';
3
+ import { extract } from './extract';
4
+ import { fade } from './fade';
5
+ import { merge } from './merge';
6
+ import { normalize } from './normalize';
7
+ import { reverse } from './reverse';
8
+ import { speed } from './speed';
9
+ import { trim } from './trim';
10
+ import { volume } from './volume';
11
+ import { waveform } from './waveform';
12
+
13
+ export const audio = defineCommand({
14
+ meta: {
15
+ name: 'audio',
16
+ description: 'Audio manipulation tools',
17
+ },
18
+ subCommands: {
19
+ // Essentials
20
+ convert,
21
+ trim,
22
+ merge,
23
+ extract,
24
+
25
+ // Effects
26
+ speed,
27
+ volume,
28
+ fade,
29
+ normalize,
30
+ reverse,
31
+
32
+ // Visualization
33
+ waveform,
34
+ },
35
+ });