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,125 @@
1
+ import { defineCommand } from 'citty';
2
+ import { type ToolInfo, detectTool, getInstallCommand } from '../utils/detect';
3
+ import { info, success, warn } from '../utils/logger';
4
+ import { c, sym } from '../utils/style';
5
+
6
+ interface ToolCheck {
7
+ name: string;
8
+ description: string;
9
+ requiredFor: string;
10
+ binary: string;
11
+ }
12
+
13
+ const TOOLS: ToolCheck[] = [
14
+ {
15
+ name: 'FFmpeg',
16
+ description: 'Audio/Video processing core',
17
+ requiredFor: 'All audio commands, video extraction',
18
+ binary: 'ffmpeg',
19
+ },
20
+ {
21
+ name: 'Ghostscript',
22
+ description: 'PDF processing engine',
23
+ requiredFor: 'High-quality PDF compression',
24
+ binary: 'gs',
25
+ },
26
+ {
27
+ name: 'QPDF',
28
+ description: 'PDF security tool',
29
+ requiredFor: 'PDF encryption and decryption',
30
+ binary: 'qpdf',
31
+ },
32
+ {
33
+ name: 'MuPDF Tools',
34
+ description: 'PDF rendering engine',
35
+ requiredFor: 'High-quality PDF-to-Image, OCR preprocessing',
36
+ binary: 'mutool',
37
+ },
38
+ {
39
+ name: 'Poppler',
40
+ description: 'PDF rendering library (Alternative)',
41
+ requiredFor: 'PDF-to-Text, Alternative PDF-to-Image',
42
+ binary: 'pdftotext',
43
+ },
44
+ {
45
+ name: 'ImageMagick',
46
+ description: 'Image manipulation suite',
47
+ requiredFor: 'Advanced image operations (fallback)',
48
+ binary: 'magick',
49
+ },
50
+ ];
51
+
52
+ export const doctor = defineCommand({
53
+ meta: {
54
+ name: 'doctor',
55
+ description: 'Check system dependencies and environment health',
56
+ },
57
+ async run() {
58
+ console.log('');
59
+ info('NoUpload CLI Doctor');
60
+ console.log(c.dim(' Checking system dependencies...\n'));
61
+
62
+ let allGood = true;
63
+ const missing: ToolCheck[] = [];
64
+
65
+ // Check each tool
66
+ for (const tool of TOOLS) {
67
+ let result: ToolInfo;
68
+
69
+ if (tool.name === 'Ghostscript') {
70
+ const { detectGhostscript } = await import('../utils/detect');
71
+ result = await detectGhostscript();
72
+ } else if (tool.name === 'MuPDF Tools') {
73
+ const { detectMutool } = await import('../utils/detect');
74
+ result = await detectMutool();
75
+ } else if (tool.name === 'QPDF') {
76
+ const { detectQpdf } = await import('../utils/detect');
77
+ result = await detectQpdf();
78
+ } else if (tool.name === 'ImageMagick') {
79
+ const { detectImageMagick } = await import('../utils/detect');
80
+ result = await detectImageMagick();
81
+ } else {
82
+ result = await detectTool(tool.binary);
83
+ }
84
+
85
+ if (result.available) {
86
+ console.log(` ${c.done(sym.done)} ${c.white(tool.name)}`);
87
+ console.log(` ${c.dim('Path:')} ${result.path}`);
88
+ console.log(` ${c.dim('Status:')} Installed`);
89
+ } else {
90
+ allGood = false;
91
+ missing.push(tool);
92
+ console.log(` ${c.error(sym.error)} ${c.white(tool.name)}`);
93
+ console.log(` ${c.dim('Required:')} ${tool.requiredFor}`);
94
+ console.log(` ${c.dim('Status:')} ${c.error('Missing')}`);
95
+ }
96
+ console.log('');
97
+ }
98
+
99
+ if (allGood) {
100
+ success('All systems operational! You are ready to use all features.');
101
+ } else {
102
+ warn('Some dependencies are missing. Certain commands may not work.');
103
+
104
+ console.log(`\n${c.title('Auto-Fix:')}`);
105
+ console.log(
106
+ ` Run ${c.active('noupload setup')} to install missing dependencies automatically.`
107
+ );
108
+ console.log(` (Use ${c.dim('--yes')} for non-interactive mode)`);
109
+
110
+ console.log(`\n${c.title('Manual Installation:')}\n`);
111
+
112
+ for (const m of missing) {
113
+ const cmd =
114
+ getInstallCommand(m.binary) || getInstallCommand(m.name.toLowerCase().split(' ')[0]);
115
+ if (cmd) {
116
+ console.log(`${c.white(m.name)}:`);
117
+ console.log(` ${c.active(cmd)}`);
118
+ console.log('');
119
+ }
120
+ }
121
+
122
+ process.exit(1);
123
+ }
124
+ },
125
+ });
@@ -0,0 +1,129 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
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 adjust = defineCommand({
10
+ meta: {
11
+ name: 'adjust',
12
+ description: 'Adjust image brightness, contrast, and saturation',
13
+ },
14
+ args: {
15
+ input: {
16
+ type: 'positional',
17
+ description: 'Input image file',
18
+ required: true,
19
+ },
20
+ output: {
21
+ type: 'string',
22
+ alias: 'o',
23
+ description: 'Output file path',
24
+ },
25
+ brightness: {
26
+ type: 'string',
27
+ alias: 'b',
28
+ description: 'Brightness multiplier (1.0 = no change)',
29
+ },
30
+ saturation: {
31
+ type: 'string',
32
+ alias: 's',
33
+ description: 'Saturation multiplier (1.0 = no change)',
34
+ },
35
+ hue: {
36
+ type: 'string',
37
+ alias: 'h',
38
+ description: 'Hue rotation in degrees',
39
+ },
40
+ contrast: {
41
+ type: 'string',
42
+ alias: 'c',
43
+ description: 'Contrast adjustment (-100 to 100)',
44
+ },
45
+ gamma: {
46
+ type: 'string',
47
+ alias: 'g',
48
+ description: 'Gamma correction (0.1 to 10)',
49
+ },
50
+ },
51
+ async run({ args }) {
52
+ try {
53
+ const input = requireArg(args.input, 'input');
54
+
55
+ const inputPath = resolvePath(input as string);
56
+ if (!existsSync(inputPath)) {
57
+ throw new FileNotFoundError(input as string);
58
+ }
59
+
60
+ const outputPath = generateOutputPath(inputPath, args.output, '-adjusted');
61
+ ensureDir(outputPath);
62
+
63
+ const inputSize = getFileSize(inputPath);
64
+ const adjustments: string[] = [];
65
+
66
+ await withSpinner(
67
+ 'Adjusting image...',
68
+ async () => {
69
+ let pipeline = sharp(inputPath);
70
+
71
+ // Apply modulate for brightness, saturation, hue
72
+ const modulate: { brightness?: number; saturation?: number; hue?: number } = {};
73
+
74
+ if (args.brightness) {
75
+ modulate.brightness = Number.parseFloat(args.brightness);
76
+ adjustments.push(`brightness: ${args.brightness}`);
77
+ }
78
+
79
+ if (args.saturation) {
80
+ modulate.saturation = Number.parseFloat(args.saturation);
81
+ adjustments.push(`saturation: ${args.saturation}`);
82
+ }
83
+
84
+ if (args.hue) {
85
+ modulate.hue = Number.parseInt(args.hue, 10);
86
+ adjustments.push(`hue: ${args.hue}°`);
87
+ }
88
+
89
+ if (Object.keys(modulate).length > 0) {
90
+ pipeline = pipeline.modulate(modulate);
91
+ }
92
+
93
+ // Apply linear for contrast
94
+ if (args.contrast) {
95
+ const contrast = Number.parseFloat(args.contrast);
96
+ // Convert -100 to 100 range to multiplier
97
+ const multiplier = 1 + contrast / 100;
98
+ const offset = 128 * (1 - multiplier);
99
+ pipeline = pipeline.linear(multiplier, offset);
100
+ adjustments.push(`contrast: ${args.contrast}`);
101
+ }
102
+
103
+ // Apply gamma
104
+ if (args.gamma) {
105
+ const gamma = Number.parseFloat(args.gamma);
106
+ pipeline = pipeline.gamma(gamma);
107
+ adjustments.push(`gamma: ${args.gamma}`);
108
+ }
109
+
110
+ await pipeline.toFile(outputPath);
111
+ },
112
+ 'Image adjusted successfully'
113
+ );
114
+
115
+ if (adjustments.length > 0) {
116
+ info(`Adjustments: ${adjustments.join(', ')}`);
117
+ }
118
+
119
+ fileResult(inputPath, outputPath, {
120
+ before: inputSize,
121
+ after: getFileSize(outputPath),
122
+ });
123
+
124
+ success('Image adjusted');
125
+ } catch (err) {
126
+ handleError(err);
127
+ }
128
+ },
129
+ });
@@ -0,0 +1,94 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
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
+ function hexToRgb(hex: string): { r: number; g: number; b: number } {
10
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
11
+ if (!result) {
12
+ return { r: 0, g: 0, b: 0 };
13
+ }
14
+ return {
15
+ r: Number.parseInt(result[1], 16),
16
+ g: Number.parseInt(result[2], 16),
17
+ b: Number.parseInt(result[3], 16),
18
+ };
19
+ }
20
+
21
+ export const border = defineCommand({
22
+ meta: {
23
+ name: 'border',
24
+ description: 'Add a border to an image',
25
+ },
26
+ args: {
27
+ input: {
28
+ type: 'positional',
29
+ description: 'Input image file',
30
+ required: true,
31
+ },
32
+ output: {
33
+ type: 'string',
34
+ alias: 'o',
35
+ description: 'Output file path',
36
+ },
37
+ size: {
38
+ type: 'string',
39
+ alias: 's',
40
+ description: 'Border size in pixels',
41
+ default: '10',
42
+ },
43
+ color: {
44
+ type: 'string',
45
+ alias: 'c',
46
+ description: 'Border color (hex)',
47
+ default: '#ffffff',
48
+ },
49
+ },
50
+ async run({ args }) {
51
+ try {
52
+ const input = requireArg(args.input, 'input');
53
+
54
+ const inputPath = resolvePath(input as string);
55
+ if (!existsSync(inputPath)) {
56
+ throw new FileNotFoundError(input as string);
57
+ }
58
+
59
+ const outputPath = generateOutputPath(inputPath, args.output, '-bordered');
60
+ ensureDir(outputPath);
61
+
62
+ const inputSize = getFileSize(inputPath);
63
+ const borderSize = Number.parseInt(args.size || '10', 10);
64
+ const color = hexToRgb(args.color || '#ffffff');
65
+
66
+ await withSpinner(
67
+ `Adding ${borderSize}px border...`,
68
+ async () => {
69
+ await sharp(inputPath)
70
+ .extend({
71
+ top: borderSize,
72
+ bottom: borderSize,
73
+ left: borderSize,
74
+ right: borderSize,
75
+ background: color,
76
+ })
77
+ .toFile(outputPath);
78
+ },
79
+ 'Border added successfully'
80
+ );
81
+
82
+ info(`Border: ${borderSize}px, Color: ${args.color || '#ffffff'}`);
83
+
84
+ fileResult(inputPath, outputPath, {
85
+ before: inputSize,
86
+ after: getFileSize(outputPath),
87
+ });
88
+
89
+ success('Border added to image');
90
+ } catch (err) {
91
+ handleError(err);
92
+ }
93
+ },
94
+ });
@@ -0,0 +1,111 @@
1
+ import { join } from 'node:path';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
4
+ import { handleError, requireArg } from '../../utils/errors';
5
+ import {
6
+ ensureOutputDir,
7
+ getBasename,
8
+ getExtension,
9
+ getFileSize,
10
+ globFiles,
11
+ resolvePath,
12
+ } from '../../utils/files';
13
+ import { bulkResult, info, success } from '../../utils/logger';
14
+ import { withProgress } from '../../utils/progress';
15
+
16
+ export const bulkCompress = defineCommand({
17
+ meta: {
18
+ name: 'bulk-compress',
19
+ description: 'Compress multiple images',
20
+ },
21
+ args: {
22
+ input: {
23
+ type: 'positional',
24
+ description: 'Input directory or glob pattern',
25
+ required: true,
26
+ },
27
+ output: {
28
+ type: 'string',
29
+ alias: 'o',
30
+ description: 'Output directory',
31
+ required: true,
32
+ },
33
+ quality: {
34
+ type: 'string',
35
+ alias: 'q',
36
+ description: 'Quality (1-100)',
37
+ default: '80',
38
+ },
39
+ },
40
+ async run({ args }) {
41
+ try {
42
+ const input = requireArg(args.input, 'input');
43
+ const output = requireArg(args.output, 'output');
44
+
45
+ const inputPath = resolvePath(input as string);
46
+ const outputDir = resolvePath(output);
47
+ ensureOutputDir(`${outputDir}/`);
48
+
49
+ // Find all image files
50
+ let pattern = inputPath;
51
+ if (!inputPath.includes('*')) {
52
+ pattern = `${inputPath}/**/*.{jpg,jpeg,png,webp,avif}`;
53
+ }
54
+
55
+ const files = await globFiles(pattern);
56
+ if (files.length === 0) {
57
+ throw new Error('No image files found');
58
+ }
59
+
60
+ info(`Found ${files.length} images`);
61
+
62
+ const quality = Number.parseInt(args.quality || '80', 10);
63
+ let totalSaved = 0;
64
+ let processed = 0;
65
+ let failed = 0;
66
+
67
+ await withProgress(files, 'Compressing', async (file) => {
68
+ try {
69
+ const basename = getBasename(file);
70
+ const ext = getExtension(file);
71
+ const outputPath = join(outputDir, `${basename}.${ext}`);
72
+
73
+ const inputSize = getFileSize(file);
74
+
75
+ let pipeline = sharp(file);
76
+
77
+ switch (ext) {
78
+ case 'jpg':
79
+ case 'jpeg':
80
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
81
+ break;
82
+ case 'png':
83
+ pipeline = pipeline.png({ quality, compressionLevel: 9 });
84
+ break;
85
+ case 'webp':
86
+ pipeline = pipeline.webp({ quality });
87
+ break;
88
+ case 'avif':
89
+ pipeline = pipeline.avif({ quality });
90
+ break;
91
+ default:
92
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
93
+ }
94
+
95
+ await pipeline.toFile(outputPath);
96
+
97
+ const outputSize = getFileSize(outputPath);
98
+ totalSaved += inputSize - outputSize;
99
+ processed++;
100
+ } catch {
101
+ failed++;
102
+ }
103
+ });
104
+
105
+ bulkResult(processed, failed, totalSaved);
106
+ success(`Compressed ${processed} images`);
107
+ } catch (err) {
108
+ handleError(err);
109
+ }
110
+ },
111
+ });
@@ -0,0 +1,114 @@
1
+ import { join } from 'node:path';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
4
+ import { handleError, requireArg } from '../../utils/errors';
5
+ import { ensureOutputDir, getBasename, globFiles, resolvePath } from '../../utils/files';
6
+ import { bulkResult, info, success } from '../../utils/logger';
7
+ import { withProgress } from '../../utils/progress';
8
+
9
+ const SUPPORTED_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'tiff'];
10
+
11
+ export const bulkConvert = defineCommand({
12
+ meta: {
13
+ name: 'bulk-convert',
14
+ description: 'Convert multiple images to a different format',
15
+ },
16
+ args: {
17
+ input: {
18
+ type: 'positional',
19
+ description: 'Input directory or glob pattern',
20
+ required: true,
21
+ },
22
+ output: {
23
+ type: 'string',
24
+ alias: 'o',
25
+ description: 'Output directory',
26
+ required: true,
27
+ },
28
+ format: {
29
+ type: 'string',
30
+ alias: 'f',
31
+ description: `Output format: ${SUPPORTED_FORMATS.join(', ')}`,
32
+ required: true,
33
+ },
34
+ quality: {
35
+ type: 'string',
36
+ alias: 'q',
37
+ description: 'Quality (1-100)',
38
+ default: '85',
39
+ },
40
+ },
41
+ async run({ args }) {
42
+ try {
43
+ const input = requireArg(args.input, 'input');
44
+ const output = requireArg(args.output, 'output');
45
+ const format = requireArg(args.format, 'format').toLowerCase();
46
+
47
+ if (!SUPPORTED_FORMATS.includes(format)) {
48
+ throw new Error(`Unsupported format: ${format}`);
49
+ }
50
+
51
+ const inputPath = resolvePath(input as string);
52
+ const outputDir = resolvePath(output);
53
+ ensureOutputDir(`${outputDir}/`);
54
+
55
+ // Find all image files
56
+ let pattern = inputPath;
57
+ if (!inputPath.includes('*')) {
58
+ pattern = `${inputPath}/**/*.{jpg,jpeg,png,webp,avif,gif,tiff,heic}`;
59
+ }
60
+
61
+ const files = await globFiles(pattern);
62
+ if (files.length === 0) {
63
+ throw new Error('No image files found');
64
+ }
65
+
66
+ info(`Found ${files.length} images`);
67
+
68
+ const quality = Number.parseInt(args.quality || '85', 10);
69
+ let processed = 0;
70
+ let failed = 0;
71
+
72
+ await withProgress(files, `Converting to ${format.toUpperCase()}`, async (file) => {
73
+ try {
74
+ const basename = getBasename(file);
75
+ const outputPath = join(outputDir, `${basename}.${format}`);
76
+
77
+ let pipeline = sharp(file);
78
+
79
+ switch (format) {
80
+ case 'jpg':
81
+ case 'jpeg':
82
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
83
+ break;
84
+ case 'png':
85
+ pipeline = pipeline.png({ quality });
86
+ break;
87
+ case 'webp':
88
+ pipeline = pipeline.webp({ quality });
89
+ break;
90
+ case 'avif':
91
+ pipeline = pipeline.avif({ quality });
92
+ break;
93
+ case 'gif':
94
+ pipeline = pipeline.gif();
95
+ break;
96
+ case 'tiff':
97
+ pipeline = pipeline.tiff({ quality });
98
+ break;
99
+ }
100
+
101
+ await pipeline.toFile(outputPath);
102
+ processed++;
103
+ } catch {
104
+ failed++;
105
+ }
106
+ });
107
+
108
+ bulkResult(processed, failed);
109
+ success(`Converted ${processed} images to ${format.toUpperCase()}`);
110
+ } catch (err) {
111
+ handleError(err);
112
+ }
113
+ },
114
+ });
@@ -0,0 +1,112 @@
1
+ import { join } from 'node:path';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
4
+ import { handleError, requireArg } from '../../utils/errors';
5
+ import {
6
+ ensureOutputDir,
7
+ getBasename,
8
+ getExtension,
9
+ globFiles,
10
+ resolvePath,
11
+ } from '../../utils/files';
12
+ import { bulkResult, info, success } from '../../utils/logger';
13
+ import { withProgress } from '../../utils/progress';
14
+
15
+ export const bulkResize = defineCommand({
16
+ meta: {
17
+ name: 'bulk-resize',
18
+ description: 'Resize multiple images',
19
+ },
20
+ args: {
21
+ input: {
22
+ type: 'positional',
23
+ description: 'Input directory or glob pattern',
24
+ required: true,
25
+ },
26
+ output: {
27
+ type: 'string',
28
+ alias: 'o',
29
+ description: 'Output directory',
30
+ required: true,
31
+ },
32
+ width: {
33
+ type: 'string',
34
+ alias: 'w',
35
+ description: 'Target width in pixels',
36
+ },
37
+ height: {
38
+ type: 'string',
39
+ alias: 'h',
40
+ description: 'Target height in pixels',
41
+ },
42
+ scale: {
43
+ type: 'string',
44
+ alias: 's',
45
+ description: 'Scale factor (e.g., 0.5)',
46
+ },
47
+ },
48
+ async run({ args }) {
49
+ try {
50
+ const input = requireArg(args.input, 'input');
51
+ const output = requireArg(args.output, 'output');
52
+
53
+ if (!args.width && !args.height && !args.scale) {
54
+ throw new Error('Specify --width, --height, or --scale');
55
+ }
56
+
57
+ const inputPath = resolvePath(input as string);
58
+ const outputDir = resolvePath(output);
59
+ ensureOutputDir(`${outputDir}/`);
60
+
61
+ // Find all image files
62
+ let pattern = inputPath;
63
+ if (!inputPath.includes('*')) {
64
+ pattern = `${inputPath}/**/*.{jpg,jpeg,png,webp,avif,gif}`;
65
+ }
66
+
67
+ const files = await globFiles(pattern);
68
+ if (files.length === 0) {
69
+ throw new Error('No image files found');
70
+ }
71
+
72
+ info(`Found ${files.length} images`);
73
+
74
+ let processed = 0;
75
+ let failed = 0;
76
+
77
+ await withProgress(files, 'Resizing', async (file) => {
78
+ try {
79
+ const basename = getBasename(file);
80
+ const ext = getExtension(file);
81
+ const outputPath = join(outputDir, `${basename}.${ext}`);
82
+
83
+ let targetWidth: number | undefined;
84
+ let targetHeight: number | undefined;
85
+
86
+ if (args.scale) {
87
+ const metadata = await sharp(file).metadata();
88
+ const scale = Number.parseFloat(args.scale);
89
+ targetWidth = Math.round((metadata.width || 0) * scale);
90
+ targetHeight = Math.round((metadata.height || 0) * scale);
91
+ } else {
92
+ targetWidth = args.width ? Number.parseInt(args.width, 10) : undefined;
93
+ targetHeight = args.height ? Number.parseInt(args.height, 10) : undefined;
94
+ }
95
+
96
+ await sharp(file)
97
+ .resize(targetWidth, targetHeight, { fit: 'inside', withoutEnlargement: true })
98
+ .toFile(outputPath);
99
+
100
+ processed++;
101
+ } catch {
102
+ failed++;
103
+ }
104
+ });
105
+
106
+ bulkResult(processed, failed);
107
+ success(`Resized ${processed} images`);
108
+ } catch (err) {
109
+ handleError(err);
110
+ }
111
+ },
112
+ });