Repository: lovell/sharp (js)

Issue:
Support outputting higher bit-depth AVIF
## Feature request

### What are you trying to achieve?

As a photo library, we convert and sanitize images to WebP and JPEG for performance. But these formats can compromise on color fidelity since they're limited to 8 bits. As a result, it's not uncommon for users to want to avoid using the converted images altogether.

AVIF, unlike these formats, allows for higher bit depths of 10 and 12. Combined with its higher compression, this helps bridge the gap between formats like WebP and the often very large originals.

AVIF also supports HDR, but this is out of scope for this request since it likely requires upstream changes in libvips.

### When you searched for similar feature requests, what did you find that might be related?

#3358 changed AVIF to always have 8-bit output for compatibility with AOM builds. Defaulting to 8 bits would still maintain this behavior, but allow for higher bit depths when the API consumer knows their build supports this.

### What would you expect the API to look like?

Similar to the TIFF output options, a `bitdepth` setting that defaults to 8 and can be configured to 10 or 12.

### What alternatives have you considered?

P3 helps, but ideally it'd be complementary to this rather than an alternative.

Ultra HDR is a way to embed gain maps into JPEG. However, it's still limited in color fidelity and doesn't benefit from AVIF's better compression. libvips also doesn't currently support Ultra HDR, so it'd be quite a bit more work.

Pull request description:
feat: bitdepth option for avif/heif
Following #4031, this PR exposes a `bitdepth` setting to allow outputting 10-bit or 12-bit AVIF/HEIF files. The default remains 8, so this doesn't change the current behavior.

Fixes #4031

Patch:
diff --git a/lib/constructor.js b/lib/constructor.js
index 05b4c2db..f6741509 100644
--- a/lib/constructor.js
+++ b/lib/constructor.js
@@ -325,6 +325,7 @@ const Sharp = function (input, options) {
     heifCompression: 'av1',
     heifEffort: 4,
     heifChromaSubsampling: '4:4:4',
+    heifBitdepth: 8,
     jxlDistance: 1,
     jxlDecodingTier: 0,
     jxlEffort: 7,
diff --git a/lib/index.d.ts b/lib/index.d.ts
index f73a7594..c31ccdfc 100644
--- a/lib/index.d.ts
+++ b/lib/index.d.ts
@@ -1244,6 +1244,8 @@ declare namespace sharp {
         effort?: number | undefined;
         /** set to '4:2:0' to use chroma subsampling, requires libvips v8.11.0 (optional, default '4:4:4') */
         chromaSubsampling?: string | undefined;
+        /** Set bitdepth to 8, 10 or 12 bit (optional, default 8) */
+        bitdepth?: 8 | 10 | 12 | undefined;
     }
 
     interface HeifOptions extends OutputOptions {
@@ -1257,6 +1259,8 @@ declare namespace sharp {
         effort?: number | undefined;
         /** set to '4:2:0' to use chroma subsampling (optional, default '4:4:4') */
         chromaSubsampling?: string | undefined;
+        /** Set bitdepth to 8, 10 or 12 bit (optional, default 8) */
+        bitdepth?: 8 | 10 | 12 | undefined;
     }
 
     interface GifOptions extends OutputOptions, AnimationOptions {
diff --git a/lib/output.js b/lib/output.js
index d192b7af..c03d6506 100644
--- a/lib/output.js
+++ b/lib/output.js
@@ -1029,6 +1029,7 @@ function tiff (options) {
  * @param {boolean} [options.lossless=false] - use lossless compression
  * @param {number} [options.effort=4] - CPU effort, between 0 (fastest) and 9 (slowest)
  * @param {string} [options.chromaSubsampling='4:4:4'] - set to '4:2:0' to use chroma subsampling
+ * @param {number} [options.bitdepth=8] - set bitdepth to 8, 10 or 12 bit
  * @returns {Sharp}
  * @throws {Error} Invalid options
  */
@@ -1055,6 +1056,7 @@ function avif (options) {
  * @param {boolean} [options.lossless=false] - use lossless compression
  * @param {number} [options.effort=4] - CPU effort, between 0 (fastest) and 9 (slowest)
  * @param {string} [options.chromaSubsampling='4:4:4'] - set to '4:2:0' to use chroma subsampling
+ * @param {number} [options.bitdepth=8] - set bitdepth to 8, 10 or 12 bit
  * @returns {Sharp}
  * @throws {Error} Invalid options
  */
@@ -1093,6 +1095,13 @@ function heif (options) {
         throw is.invalidParameterError('chromaSubsampling', 'one of: 4:2:0, 4:4:4', options.chromaSubsampling);
       }
     }
+    if (is.defined(options.bitdepth)) {
+      if (is.integer(options.bitdepth) && is.inArray(options.bitdepth, [8, 10, 12])) {
+        this.options.heifBitdepth = options.bitdepth;
+      } else {
+        throw is.invalidParameterError('bitdepth', '8, 10 or 12', options.bitdepth);
+      }
+    }
   } else {
     throw is.invalidParameterError('options', 'Object', options);
   }
diff --git a/src/pipeline.cc b/src/pipeline.cc
index ce5ce7ba..a9c68f12 100644
--- a/src/pipeline.cc
+++ b/src/pipeline.cc
@@ -989,7 +989,7 @@ class PipelineWorker : public Napi::AsyncWorker {
             ->set("Q", baton->heifQuality)
             ->set("compression", baton->heifCompression)
             ->set("effort", baton->heifEffort)
-            ->set("bitdepth", 8)
+            ->set("bitdepth", baton->heifBitdepth)
             ->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4"
               ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON)
             ->set("lossless", baton->heifLossless)));
@@ -1182,7 +1182,7 @@ class PipelineWorker : public Napi::AsyncWorker {
             ->set("Q", baton->heifQuality)
             ->set("compression", baton->heifCompression)
             ->set("effort", baton->heifEffort)
-            ->set("bitdepth", 8)
+            ->set("bitdepth", baton->heifBitdepth)
             ->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4"
               ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON)
             ->set("lossless", baton->heifLossless));
@@ -1696,6 +1696,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
     options, "heifCompression", VIPS_TYPE_FOREIGN_HEIF_COMPRESSION);
   baton->heifEffort = sharp::AttrAsUint32(options, "heifEffort");
   baton->heifChromaSubsampling = sharp::AttrAsStr(options, "heifChromaSubsampling");
+  baton->heifBitdepth = sharp::AttrAsUint32(options, "heifBitdepth");
   baton->jxlDistance = sharp::AttrAsDouble(options, "jxlDistance");
   baton->jxlDecodingTier = sharp::AttrAsUint32(options, "jxlDecodingTier");
   baton->jxlEffort = sharp::AttrAsUint32(options, "jxlEffort");
diff --git a/src/pipeline.h b/src/pipeline.h
index 3d92dbac..6ebf0e63 100644
--- a/src/pipeline.h
+++ b/src/pipeline.h
@@ -181,6 +181,7 @@ struct PipelineBaton {
   int heifEffort;
   std::string heifChromaSubsampling;
   bool heifLossless;
+  int heifBitdepth;
   double jxlDistance;
   int jxlDecodingTier;
   int jxlEffort;
@@ -349,6 +350,7 @@ struct PipelineBaton {
     heifEffort(4),
     heifChromaSubsampling("4:4:4"),
     heifLossless(false),
+    heifBitdepth(8),
     jxlDistance(1.0),
     jxlDecodingTier(0),
     jxlEffort(7),


Test patch:
diff --git a/test/unit/avif.js b/test/unit/avif.js
index ca9a2bc2..3075d365 100644
--- a/test/unit/avif.js
+++ b/test/unit/avif.js
@@ -144,4 +144,10 @@ describe('AVIF', () => {
       /Processed image is too large for the HEIF format/
     )
   );
+
+  it('Invalid bitdepth value throws error', async () => {
+    assert.rejects(
+      () => sharp().avif({ bitdepth: 11 }),
+      /Error: Expected 8, 10 or 12 for bitdepth but received 11 of type number/);
+  });
 });
diff --git a/test/unit/heif.js b/test/unit/heif.js
index 338bda69..ccd6a399 100644
--- a/test/unit/heif.js
+++ b/test/unit/heif.js
@@ -78,4 +78,14 @@ describe('HEIF', () => {
       sharp().heif({ compression: 'av1', chromaSubsampling: '4:4:4' });
     });
   });
+  it('valid bitdepth value does not throw an error', () => {
+    assert.doesNotThrow(() => {
+      sharp().heif({ compression: 'av1', bitdepth: 12 });
+    });
+  });
+  it('invalid bitdepth value should throw an error', () => {
+    assert.throws(() => {
+      sharp().heif({ compression: 'av1', bitdepth: 11 });
+    }, /Error: Expected 8, 10 or 12 for bitdepth but received 11 of type number/);
+  });
 });