@muhgholy/next-drive 4.4.0 → 4.6.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/README.md CHANGED
@@ -55,17 +55,6 @@ This package uses [subpath exports](https://nodejs.org/api/packages.html#subpath
55
55
  }
56
56
  ```
57
57
 
58
- **For projects using bundlers (Vite, Webpack, etc.):**
59
-
60
- ```json
61
- {
62
- "compilerOptions": {
63
- "module": "esnext",
64
- "moduleResolution": "bundler"
65
- }
66
- }
67
- ```
68
-
69
58
  > ⚠️ The legacy `"moduleResolution": "node"` is **not supported** and will cause build errors with subpath imports like `@muhgholy/next-drive/server`.
70
59
 
71
60
  **FFmpeg** (for video thumbnails):
@@ -583,25 +572,122 @@ GOOGLE_REDIRECT_URI=http://localhost:3000/api/drive?action=callback
583
572
 
584
573
  ---
585
574
 
586
- ## API Endpoints
587
-
588
- All operations use `?action=` query parameter:
589
-
590
- | Action | Method | Description |
591
- | ----------------- | ------ | -------------------- |
592
- | `upload` | POST | Chunked file upload |
593
- | `list` | GET | List folder contents |
594
- | `serve` | GET | Serve file content |
595
- | `thumbnail` | GET | Get file thumbnail |
596
- | `rename` | PATCH | Rename file/folder |
597
- | `trash` | POST | Move to trash |
598
- | `deletePermanent` | DELETE | Delete permanently |
599
- | `restore` | POST | Restore from trash |
600
- | `emptyTrash` | DELETE | Empty all trash |
601
- | `createFolder` | POST | Create folder |
602
- | `move` | POST | Move to new parent |
603
- | `search` | GET | Search by name |
604
- | `quota` | GET | Get storage usage |
575
+ ## Image Optimization
576
+
577
+ Serve optimized images with dynamic compression, resizing, and format conversion using query parameters.
578
+
579
+ ### URL Format
580
+
581
+ ```
582
+ /api/drive?action=serve&id={fileId}&quality={preset}&display={context}&size={preset}&format={format}
583
+ ```
584
+
585
+ ### Parameters
586
+
587
+ | Parameter | Type | Description |
588
+ |-----------|------|-------------|
589
+ | `quality` | `low` / `medium` / `high` / `1-100` | Compression level |
590
+ | `display` | string | Context-based quality adjustment |
591
+ | `size` | string | Predefined dimensions preset |
592
+ | `format` | `jpeg` / `webp` / `avif` / `png` | Output format |
593
+
594
+ ### Quality Presets
595
+
596
+ | Preset | Base Quality | Use Case |
597
+ |--------|--------------|----------|
598
+ | `low` | 30 | Thumbnails, previews |
599
+ | `medium` | 50 | General content |
600
+ | `high` | 75 | High-quality display |
601
+ | `1-100` | Custom | Fine-tuned control |
602
+
603
+ > Quality is dynamically adjusted based on file size. Larger files get more aggressive compression.
604
+
605
+ ### Display Presets (Quality Context)
606
+
607
+ | Display | Quality Factor | Use Case |
608
+ |---------|----------------|----------|
609
+ | `article-header` | 0.9 | Hero/banner images |
610
+ | `article-image` | 0.85 | In-content images |
611
+ | `thumbnail` | 0.7 | Small previews |
612
+ | `avatar` | 0.8 | Profile pictures |
613
+ | `logo` | 0.95 | Branding/logos |
614
+ | `card` | 0.8 | Card components |
615
+ | `gallery` | 0.85 | Gallery/grid |
616
+ | `og` | 0.9 | Open Graph/social |
617
+ | `icon` | 0.75 | Small icons |
618
+ | `cover` | 0.9 | Full-width covers |
619
+ | `story` | 0.85 | Story/vertical |
620
+
621
+ ### Size Presets (Dimensions)
622
+
623
+ **Square Sizes:**
624
+ | Size | Dimensions |
625
+ |------|------------|
626
+ | `xs` | 64×64 |
627
+ | `sm` | 128×128 |
628
+ | `md` | 256×256 |
629
+ | `lg` | 512×512 |
630
+ | `xl` | 1024×1024 |
631
+ | `2xl` | 1600×1600 |
632
+ | `icon` | 48×48 |
633
+ | `thumb` | 150×150 |
634
+ | `square` | 600×600 |
635
+ | `avatar-sm` | 64×64 |
636
+ | `avatar-md` | 128×128 |
637
+ | `avatar-lg` | 256×256 |
638
+
639
+ **Landscape (16:9):**
640
+ | Size | Dimensions |
641
+ |------|------------|
642
+ | `landscape-sm` | 480×270 |
643
+ | `landscape` | 800×450 |
644
+ | `landscape-lg` | 1280×720 |
645
+ | `landscape-xl` | 1920×1080 |
646
+
647
+ **Portrait (9:16):**
648
+ | Size | Dimensions |
649
+ |------|------------|
650
+ | `portrait-sm` | 270×480 |
651
+ | `portrait` | 450×800 |
652
+ | `portrait-lg` | 720×1280 |
653
+
654
+ **Wide/Banner:**
655
+ | Size | Dimensions | Ratio |
656
+ |------|------------|-------|
657
+ | `wide` | 1200×630 | OG standard |
658
+ | `banner` | 1200×400 | 3:1 |
659
+ | `banner-sm` | 800×200 | 4:1 |
660
+
661
+ **Other:**
662
+ | Size | Dimensions | Ratio |
663
+ |------|------------|-------|
664
+ | `photo-4x3` | 800×600 | 4:3 |
665
+ | `photo-3x2` | 900×600 | 3:2 |
666
+ | `story` | 1080×1920 | 9:16 |
667
+ | `video` | 1280×720 | 16:9 |
668
+ | `video-sm` | 640×360 | 16:9 |
669
+ | `card-sm` | 300×200 | 3:2 |
670
+ | `card` | 400×300 | 4:3 |
671
+ | `card-lg` | 600×400 | 3:2 |
672
+
673
+ ### Examples
674
+
675
+ ```html
676
+ <!-- Article header with OG dimensions -->
677
+ <img src="/api/drive?action=serve&id=123&display=article-header&size=wide&format=webp">
678
+
679
+ <!-- Thumbnail with aggressive compression -->
680
+ <img src="/api/drive?action=serve&id=123&display=thumbnail&size=thumb&format=webp">
681
+
682
+ <!-- Avatar -->
683
+ <img src="/api/drive?action=serve&id=123&display=avatar&size=avatar-md&format=webp">
684
+
685
+ <!-- Gallery image -->
686
+ <img src="/api/drive?action=serve&id=123&display=gallery&size=landscape&format=webp">
687
+
688
+ <!-- Just quality, no resize -->
689
+ <img src="/api/drive?action=serve&id=123&quality=medium&format=webp">
690
+ ```
605
691
 
606
692
  ---
607
693
 
@@ -369,6 +369,121 @@ var extractImageMetadata = async (filePath) => {
369
369
  return null;
370
370
  }
371
371
  };
372
+ var DISPLAY_PRESETS = {
373
+ "article-header": 0.9,
374
+ // Hero/banner images - high quality
375
+ "article-image": 0.85,
376
+ // In-content images
377
+ "thumbnail": 0.7,
378
+ // Small previews - lower quality ok
379
+ "avatar": 0.8,
380
+ // Profile pictures
381
+ "logo": 0.95,
382
+ // Branding - needs clarity
383
+ "card": 0.8,
384
+ // Card components
385
+ "gallery": 0.85,
386
+ // Gallery/grid images
387
+ "og": 0.9,
388
+ // Open Graph/social sharing
389
+ "icon": 0.75,
390
+ // Small icons
391
+ "cover": 0.9,
392
+ // Full-width covers
393
+ "story": 0.85
394
+ // Story/vertical format
395
+ };
396
+ var SIZE_PRESETS = {
397
+ // Square sizes
398
+ "xs": { width: 64, height: 64 },
399
+ "sm": { width: 128, height: 128 },
400
+ "md": { width: 256, height: 256 },
401
+ "lg": { width: 512, height: 512 },
402
+ "xl": { width: 1024, height: 1024 },
403
+ "2xl": { width: 1600, height: 1600 },
404
+ // Named squares
405
+ "icon": { width: 48, height: 48 },
406
+ "thumb": { width: 150, height: 150 },
407
+ "square": { width: 600, height: 600 },
408
+ "avatar-sm": { width: 64, height: 64 },
409
+ "avatar-md": { width: 128, height: 128 },
410
+ "avatar-lg": { width: 256, height: 256 },
411
+ // Landscape (16:9)
412
+ "landscape-sm": { width: 480, height: 270 },
413
+ "landscape": { width: 800, height: 450 },
414
+ "landscape-lg": { width: 1280, height: 720 },
415
+ "landscape-xl": { width: 1920, height: 1080 },
416
+ // Portrait (9:16)
417
+ "portrait-sm": { width: 270, height: 480 },
418
+ "portrait": { width: 450, height: 800 },
419
+ "portrait-lg": { width: 720, height: 1280 },
420
+ // Wide/Banner (OG, social)
421
+ "wide": { width: 1200, height: 630 },
422
+ // Open Graph standard
423
+ "banner": { width: 1200, height: 400 },
424
+ // Banner/header
425
+ "banner-sm": { width: 800, height: 200 },
426
+ // Classic photo ratios
427
+ "photo-4x3": { width: 800, height: 600 },
428
+ // 4:3
429
+ "photo-3x2": { width: 900, height: 600 },
430
+ // 3:2
431
+ // Story/vertical (9:16)
432
+ "story": { width: 1080, height: 1920 },
433
+ // Video thumbnails
434
+ "video": { width: 1280, height: 720 },
435
+ "video-sm": { width: 640, height: 360 },
436
+ // Card sizes
437
+ "card-sm": { width: 300, height: 200 },
438
+ "card": { width: 400, height: 300 },
439
+ "card-lg": { width: 600, height: 400 }
440
+ };
441
+ var getImageSettings = (fileSizeInBytes, qualityPreset, display, size) => {
442
+ let baseQuality = 80;
443
+ if (qualityPreset === "low") baseQuality = 30;
444
+ else if (qualityPreset === "medium") baseQuality = 50;
445
+ else if (qualityPreset === "high") baseQuality = 75;
446
+ else if (qualityPreset) {
447
+ const n = parseInt(qualityPreset, 10);
448
+ if (!isNaN(n)) baseQuality = Math.min(100, Math.max(1, n));
449
+ }
450
+ const displayFactor = display && DISPLAY_PRESETS[display] ? DISPLAY_PRESETS[display] : 1;
451
+ baseQuality = Math.round(baseQuality * displayFactor);
452
+ let quality = baseQuality;
453
+ let effort = 4;
454
+ let pngCompression = 6;
455
+ if (fileSizeInBytes) {
456
+ const sizeInKB = fileSizeInBytes / 1024;
457
+ if (sizeInKB > 500) {
458
+ quality = Math.min(baseQuality, 25);
459
+ effort = 9;
460
+ pngCompression = 9;
461
+ } else if (sizeInKB > 300) {
462
+ quality = Math.min(baseQuality, 30);
463
+ effort = 8;
464
+ pngCompression = 9;
465
+ } else if (sizeInKB > 150) {
466
+ quality = Math.min(baseQuality, 35);
467
+ effort = 7;
468
+ pngCompression = 8;
469
+ } else if (sizeInKB > 90) {
470
+ quality = Math.min(baseQuality, 40);
471
+ effort = 6;
472
+ pngCompression = 8;
473
+ } else if (sizeInKB > 50) {
474
+ quality = Math.min(baseQuality, 50);
475
+ effort = 5;
476
+ pngCompression = 7;
477
+ }
478
+ }
479
+ const dimensions = size && SIZE_PRESETS[size] ? SIZE_PRESETS[size] : void 0;
480
+ return {
481
+ quality: Math.max(1, Math.min(100, quality)),
482
+ effort,
483
+ pngCompression,
484
+ ...dimensions && { width: dimensions.width, height: dimensions.height }
485
+ };
486
+ };
372
487
  var objectIdSchema = zod.z.string().refine((val) => mongoose.isValidObjectId(val), {
373
488
  message: "Invalid ObjectId format"
374
489
  });
@@ -1553,14 +1668,76 @@ var driveAPIHandler = async (req, res) => {
1553
1668
  return;
1554
1669
  }
1555
1670
  if (action === "serve") {
1556
- const { stream, mime, size } = await itemProvider.openStream(drive, itemAccountId);
1671
+ const { stream, mime, size: fileSize } = await itemProvider.openStream(drive, itemAccountId);
1557
1672
  const safeFilename = sanitizeContentDispositionFilename(drive.name);
1673
+ const format = req.query.format;
1674
+ const quality = req.query.quality;
1675
+ const display = req.query.display;
1676
+ const sizePreset = req.query.size;
1677
+ const isImage = mime.startsWith("image/");
1678
+ const shouldTransform = isImage && (format || quality || display || sizePreset);
1558
1679
  res.setHeader("Content-Disposition", `inline; filename="${safeFilename}"`);
1559
- res.setHeader("Content-Type", mime);
1560
1680
  if (config.cors?.enabled) {
1561
1681
  res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
1562
1682
  }
1563
- if (size) res.setHeader("Content-Length", size);
1683
+ if (shouldTransform) {
1684
+ try {
1685
+ const settings = getImageSettings(fileSize, quality, display, sizePreset);
1686
+ let targetFormat = format || mime.split("/")[1];
1687
+ if (targetFormat === "jpg") targetFormat = "jpeg";
1688
+ const cacheDir = path__default.default.join(config.storage.path, "file", drive._id.toString(), "cache");
1689
+ const cacheKey = [
1690
+ "opt",
1691
+ `q${settings.quality}`,
1692
+ `e${settings.effort}`,
1693
+ settings.width ? `${settings.width}x${settings.height}` : "orig",
1694
+ targetFormat
1695
+ ].join("_");
1696
+ const cachePath = path__default.default.join(cacheDir, `${cacheKey}.bin`);
1697
+ if (fs__default.default.existsSync(cachePath)) {
1698
+ const cacheStat = fs__default.default.statSync(cachePath);
1699
+ res.setHeader("Content-Type", `image/${targetFormat}`);
1700
+ res.setHeader("Content-Length", cacheStat.size);
1701
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
1702
+ if (config.cors?.enabled) {
1703
+ res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
1704
+ }
1705
+ if ("destroy" in stream) stream.destroy();
1706
+ fs__default.default.createReadStream(cachePath).pipe(res);
1707
+ return;
1708
+ }
1709
+ if (!fs__default.default.existsSync(cacheDir)) fs__default.default.mkdirSync(cacheDir, { recursive: true });
1710
+ let pipeline = sharp__default.default();
1711
+ if (settings.width && settings.height) {
1712
+ pipeline = pipeline.resize(settings.width, settings.height, {
1713
+ fit: "inside",
1714
+ withoutEnlargement: true
1715
+ });
1716
+ }
1717
+ if (targetFormat === "jpeg") {
1718
+ pipeline = pipeline.jpeg({ quality: settings.quality, mozjpeg: true });
1719
+ res.setHeader("Content-Type", "image/jpeg");
1720
+ } else if (targetFormat === "png") {
1721
+ pipeline = pipeline.png({ compressionLevel: settings.pngCompression, adaptiveFiltering: true });
1722
+ res.setHeader("Content-Type", "image/png");
1723
+ } else if (targetFormat === "webp") {
1724
+ pipeline = pipeline.webp({ quality: settings.quality, effort: settings.effort });
1725
+ res.setHeader("Content-Type", "image/webp");
1726
+ } else if (targetFormat === "avif") {
1727
+ pipeline = pipeline.avif({ quality: settings.quality, effort: settings.effort });
1728
+ res.setHeader("Content-Type", "image/avif");
1729
+ }
1730
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
1731
+ stream.pipe(pipeline);
1732
+ pipeline.clone().toFile(cachePath).catch((e) => console.error("[next-drive] Cache write failed:", e));
1733
+ pipeline.clone().pipe(res);
1734
+ return;
1735
+ } catch (e) {
1736
+ console.error("[next-drive] Image transformation failed:", e);
1737
+ }
1738
+ }
1739
+ res.setHeader("Content-Type", mime);
1740
+ if (fileSize) res.setHeader("Content-Length", fileSize);
1564
1741
  stream.pipe(res);
1565
1742
  return;
1566
1743
  }
@@ -2105,5 +2282,5 @@ exports.driveReadFile = driveReadFile;
2105
2282
  exports.driveUpload = driveUpload;
2106
2283
  exports.getDriveConfig = getDriveConfig;
2107
2284
  exports.getDriveInformation = getDriveInformation;
2108
- //# sourceMappingURL=chunk-ZNXH3LYN.cjs.map
2109
- //# sourceMappingURL=chunk-ZNXH3LYN.cjs.map
2285
+ //# sourceMappingURL=chunk-I3AR7V7Z.cjs.map
2286
+ //# sourceMappingURL=chunk-I3AR7V7Z.cjs.map