Add a --jobs flag to avifgainmaputil for multithreading support. (#3115)

Default to using all available cores like avifenc/avifdec.
Also anable automatic tiling (avifenc default, not configurable for now in avifgainmaputil).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55e3bf6..a6894aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,9 +8,15 @@
 
 ## [Unreleased]
 
+### Added since 1.4.1
+
+* Add --jobs flag to avifgainmaputil to use multiple worker threads when
+  reading/writing AVIF files.
+
 ### Changed since 1.4.1
 
 * Require C11 for compilation. Public headers will remain C99.
+* Add --jobs flag to avifgainmaputil and enable auto tiling.
 
 ## [1.4.1] - 2026-03-20
 
diff --git a/apps/avifenc.c b/apps/avifenc.c
index be8ad96..b81194b 100644
--- a/apps/avifenc.c
+++ b/apps/avifenc.c
@@ -1143,7 +1143,7 @@
 
     char manualTilingStr[128];
     snprintf(manualTilingStr, sizeof(manualTilingStr), "tileRowsLog2 [%d], tileColsLog2 [%d]", encoder->tileRowsLog2, encoder->tileColsLog2);
-
+    // Note: this is mirrored in apps/avifgainmaputil/imageio.cc, changes here may be mirrored there if relevant.
     printf("Encoding with initial settings: codec '%s' speed [%s], color quality [%d (%s)], alpha quality [%d (%s)]%s, %s, %d worker thread(s), please wait...\n",
            codecName ? codecName : "none",
            speedStr,
diff --git a/apps/avifgainmaputil/combine_command.cc b/apps/avifgainmaputil/combine_command.cc
index 654316d..ce59cb6 100644
--- a/apps/avifgainmaputil/combine_command.cc
+++ b/apps/avifgainmaputil/combine_command.cc
@@ -76,6 +76,7 @@
           "expressed as:  MaxCLL,MaxPALL.");
   arg_image_encode_.Init(argparse_, /*can_have_alpha=*/true);
   arg_image_read_.Init(argparse_);
+  arg_jobs_.Init(argparse_);
 }
 
 avifResult CombineCommand::Run() {
@@ -95,9 +96,10 @@
         arg_base_cicp_.value().transfer_characteristics;
     base_image->matrixCoefficients = arg_base_cicp_.value().matrix_coefficients;
   }
-  avifResult result = ReadImage(
-      base_image.get(), arg_base_filename_, pixel_format, arg_image_read_.depth,
-      arg_image_read_.ignore_profile, /*ignore_gain_map=*/true);
+  avifResult result =
+      ReadImage(base_image.get(), arg_base_filename_, pixel_format,
+                arg_image_read_.depth, arg_image_read_.ignore_profile,
+                /*ignore_gain_map=*/true, arg_jobs_.jobs.value());
   if (result != AVIF_RESULT_OK) {
     std::cout << "Failed to read base image: " << avifResultToString(result)
               << "\n";
@@ -114,7 +116,8 @@
   }
   result =
       ReadImage(alternate_image.get(), arg_alternate_filename_, pixel_format,
-                arg_image_read_.depth, arg_image_read_.ignore_profile);
+                arg_image_read_.depth, arg_image_read_.ignore_profile,
+                /*ignore_gain_map=*/true, arg_jobs_.jobs.value());
   if (result != AVIF_RESULT_OK) {
     std::cout << "Failed to read alternate image: "
               << avifResultToString(result) << "\n";
@@ -189,6 +192,8 @@
   encoder->qualityAlpha = arg_image_encode_.quality_alpha;
   encoder->qualityGainMap = arg_gain_map_quality_;
   encoder->speed = arg_image_encode_.speed;
+  encoder->maxThreads = arg_jobs_.jobs.value();
+  encoder->autoTiling = true;  // Match avifenc default.
   result =
       WriteAvifGrid(base_image.get(), arg_image_encode_.grid.value().grid_cols,
                     arg_image_encode_.grid.value().grid_rows, encoder.get(),
diff --git a/apps/avifgainmaputil/combine_command.h b/apps/avifgainmaputil/combine_command.h
index 9f1feec..d530522 100644
--- a/apps/avifgainmaputil/combine_command.h
+++ b/apps/avifgainmaputil/combine_command.h
@@ -29,6 +29,7 @@
   argparse::ArgValue<float> arg_max_headroom_;
   BasicImageEncodeArgs arg_image_encode_;
   ImageReadArgs arg_image_read_;
+  JobsArgs arg_jobs_;
 };
 
 }  // namespace avif
diff --git a/apps/avifgainmaputil/convert_command.cc b/apps/avifgainmaputil/convert_command.cc
index 06bb949..1cbe74e 100644
--- a/apps/avifgainmaputil/convert_command.cc
+++ b/apps/avifgainmaputil/convert_command.cc
@@ -36,6 +36,7 @@
           "expressed as:  MaxCLL,MaxPALL.");
   arg_image_encode_.Init(argparse_, /*can_have_alpha=*/false);
   arg_image_read_.Init(argparse_);
+  arg_jobs_.Init(argparse_);
 }
 
 avifResult ConvertCommand::Run() {
@@ -61,7 +62,7 @@
   avifResult result =
       ReadImage(image.get(), arg_input_filename_.value(), pixel_format,
                 arg_image_read_.depth, arg_image_read_.ignore_profile,
-                /*ignore_gain_map*/ false);
+                /*ignore_gain_map=*/false, arg_jobs_.jobs.value());
   if (result != AVIF_RESULT_OK) {
     std::cout << "Failed to decode image: " << arg_input_filename_;
     return result;
@@ -112,6 +113,8 @@
   encoder->qualityAlpha = arg_image_encode_.quality_alpha;
   encoder->qualityGainMap = arg_gain_map_quality_;
   encoder->speed = arg_image_encode_.speed;
+  encoder->maxThreads = arg_jobs_.jobs.value();
+  encoder->autoTiling = true;  // Match avifenc default.
   result = WriteAvifGrid(image.get(), arg_image_encode_.grid.value().grid_cols,
                          arg_image_encode_.grid.value().grid_rows,
                          encoder.get(), arg_output_filename_);
diff --git a/apps/avifgainmaputil/convert_command.h b/apps/avifgainmaputil/convert_command.h
index a8011ba..778b514 100644
--- a/apps/avifgainmaputil/convert_command.h
+++ b/apps/avifgainmaputil/convert_command.h
@@ -26,6 +26,7 @@
   argparse::ArgValue<int> arg_gain_map_pixel_format_;
   BasicImageEncodeArgs arg_image_encode_;
   ImageReadArgs arg_image_read_;
+  JobsArgs arg_jobs_;
 };
 
 }  // namespace avif
diff --git a/apps/avifgainmaputil/extractgainmap_command.cc b/apps/avifgainmaputil/extractgainmap_command.cc
index 42e3f8b..6ec9e5e 100644
--- a/apps/avifgainmaputil/extractgainmap_command.cc
+++ b/apps/avifgainmaputil/extractgainmap_command.cc
@@ -14,6 +14,7 @@
   argparse_.add_argument(arg_input_filename_, "input_filename");
   argparse_.add_argument(arg_output_filename_, "output_filename");
   arg_image_encode_.Init(argparse_, /*can_have_alpha=*/false);
+  arg_jobs_.Init(argparse_);
 }
 
 avifResult ExtractGainMapCommand::Run() {
@@ -21,6 +22,7 @@
   if (decoder == nullptr) {
     return AVIF_RESULT_OUT_OF_MEMORY;
   }
+  decoder->maxThreads = arg_jobs_.jobs.value();
   decoder->imageContentToDecode = AVIF_IMAGE_CONTENT_GAIN_MAP;
 
   avifResult result =
@@ -36,10 +38,11 @@
     return AVIF_RESULT_INVALID_ARGUMENT;
   }
 
-  return WriteImage(
-      decoder->image->gainMap->image, arg_image_encode_.grid.value().grid_cols,
-      arg_image_encode_.grid.value().grid_rows, arg_output_filename_,
-      arg_image_encode_.quality, arg_image_encode_.speed);
+  return WriteImage(decoder->image->gainMap->image,
+                    arg_image_encode_.grid.value().grid_cols,
+                    arg_image_encode_.grid.value().grid_rows,
+                    arg_output_filename_, arg_image_encode_.quality,
+                    arg_image_encode_.speed, arg_jobs_.jobs.value());
 }
 
 }  // namespace avif
diff --git a/apps/avifgainmaputil/extractgainmap_command.h b/apps/avifgainmaputil/extractgainmap_command.h
index 9872a18..959e503 100644
--- a/apps/avifgainmaputil/extractgainmap_command.h
+++ b/apps/avifgainmaputil/extractgainmap_command.h
@@ -18,6 +18,7 @@
   argparse::ArgValue<std::string> arg_input_filename_;
   argparse::ArgValue<std::string> arg_output_filename_;
   BasicImageEncodeArgs arg_image_encode_;
+  JobsArgs arg_jobs_;
 };
 
 }  // namespace avif
diff --git a/apps/avifgainmaputil/imageio.cc b/apps/avifgainmaputil/imageio.cc
index 1686f55..d50a862 100644
--- a/apps/avifgainmaputil/imageio.cc
+++ b/apps/avifgainmaputil/imageio.cc
@@ -24,7 +24,7 @@
 
 avifResult WriteImage(const avifImage* image, int grid_cols, int grid_rows,
                       const std::string& output_filename, int quality,
-                      int speed) {
+                      int speed, int jobs) {
   quality = Clamp(quality, 0, 100);
   speed = Clamp(speed, 0, 10);
   const avifAppFileFormat output_format =
@@ -55,6 +55,7 @@
     }
     encoder->quality = quality;
     encoder->speed = speed;
+    encoder->maxThreads = jobs;
     return WriteAvifGrid(image, grid_cols, grid_rows, encoder.get(),
                          output_filename);
   } else {
@@ -65,6 +66,47 @@
   return AVIF_RESULT_OK;
 }
 
+namespace {
+std::string QualityLevelString(int quality) {
+  if (quality == AVIF_QUALITY_LOSSLESS) {
+    return "Lossless";
+  }
+  if (quality >= 80) {
+    return "High";
+  }
+  if (quality >= 50) {
+    return "Medium";
+  }
+  if (quality == AVIF_QUALITY_WORST) {
+    return "Worst";
+  }
+  return "Low";
+}
+
+// Based on avifenc.c, changes here may be mirrored there if relevant.
+void PrintEncodingSettings(const avifEncoder* encoder, bool has_gain_map) {
+  std::string gain_map_str;
+  if (has_gain_map) {
+    gain_map_str = ", gain map quality [" +
+                   std::to_string(encoder->qualityGainMap) + " (" +
+                   QualityLevelString(encoder->qualityGainMap) + ")]";
+  }
+  std::string manual_tiling_str =
+      "tileRowsLog2 [" + std::to_string(encoder->tileRowsLog2) +
+      "], tileColsLog2 [" + std::to_string(encoder->tileColsLog2) + "]";
+  std::cout << "Encoding AVIF with settings: codec '"
+            << avifCodecName(encoder->codecChoice, AVIF_CODEC_FLAG_CAN_ENCODE)
+            << "' speed ['" << encoder->speed << "'], color quality ['"
+            << encoder->quality << "' (" << QualityLevelString(encoder->quality)
+            << ")], alpha quality ['" << encoder->qualityAlpha << "' ("
+            << QualityLevelString(encoder->qualityAlpha) << ")]" << gain_map_str
+            << ", "
+            << (encoder->autoTiling ? "automatic tiling" : manual_tiling_str)
+            << ", " << encoder->maxThreads
+            << " worker thread(s), please wait...\n";
+}
+}  // namespace
+
 avifResult WriteAvif(const avifImage* image, avifEncoder* encoder,
                      const std::string& output_filename) {
   avifRWData encoded = AVIF_DATA_EMPTY;
@@ -72,8 +114,7 @@
   avifImageDump(image,
                 /*gridCols=*/1,
                 /*gridRows=*/1, AVIF_PROGRESSIVE_STATE_UNAVAILABLE);
-  std::cout << "Encoding AVIF at quality " << encoder->quality << " speed "
-            << encoder->speed << ", please wait...\n";
+  PrintEncodingSettings(encoder, image->gainMap != nullptr);
   avifResult result = avifEncoderWrite(encoder, image, &encoded);
   if (result != AVIF_RESULT_OK) {
     std::cerr << "Failed to encode image: " << avifResultToString(result)
@@ -122,8 +163,7 @@
   std::cout << "AVIF to be written:\n";
   avifImageDump(image, grid_cols, grid_rows,
                 AVIF_PROGRESSIVE_STATE_UNAVAILABLE);
-  std::cout << "Encoding AVIF at quality " << encoder->quality << " speed "
-            << encoder->speed << ", please wait...\n";
+  PrintEncodingSettings(encoder, image->gainMap != nullptr);
   avifResult result = avifEncoderAddImageGrid(
       encoder, grid_cols, grid_rows,
       const_cast<const avifImage* const*>(grid_cells_ptrs.data()),
@@ -155,7 +195,7 @@
 
 avifResult ReadImage(avifImage* image, const std::string& input_filename,
                      avifPixelFormat requested_format, uint32_t requested_depth,
-                     bool ignore_profile, bool ignore_gain_map) {
+                     bool ignore_profile, bool ignore_gain_map, int jobs) {
   avifAppFileFormat input_format = avifGuessFileFormat(input_filename.c_str());
   if (input_format == AVIF_APP_FILE_FORMAT_UNKNOWN) {
     std::cerr << "Cannot determine input format: " << input_filename;
@@ -168,6 +208,7 @@
     if (!ignore_gain_map) {
       decoder->imageContentToDecode |= AVIF_IMAGE_CONTENT_GAIN_MAP;
     }
+    decoder->maxThreads = jobs;
     avifResult result = ReadAvif(decoder.get(), input_filename, ignore_profile);
     if (result != AVIF_RESULT_OK) {
       return result;
diff --git a/apps/avifgainmaputil/imageio.h b/apps/avifgainmaputil/imageio.h
index c3d494b..bc69c63 100644
--- a/apps/avifgainmaputil/imageio.h
+++ b/apps/avifgainmaputil/imageio.h
@@ -15,11 +15,11 @@
 // other formats.
 avifResult WriteImage(const avifImage* image, int grid_cols, int grid_rows,
                       const std::string& output_filename, int quality,
-                      int speed);
+                      int speed, int jobs);
 // Reads an image in any of the supported formats. Ignores any gain map.
 avifResult ReadImage(avifImage* image, const std::string& input_filename,
                      avifPixelFormat requested_format, uint32_t requested_depth,
-                     bool ignore_profile, bool ignore_gain_map = true);
+                     bool ignore_profile, bool ignore_gain_map, int jobs);
 
 // Reads an image in avif format given a pre-configured encoder.
 avifResult WriteAvif(const avifImage* image, avifEncoder* encoder,
diff --git a/apps/avifgainmaputil/printmetadata_command.cc b/apps/avifgainmaputil/printmetadata_command.cc
index 6f98aa8..93350e9 100644
--- a/apps/avifgainmaputil/printmetadata_command.cc
+++ b/apps/avifgainmaputil/printmetadata_command.cc
@@ -34,6 +34,7 @@
     : ProgramCommand("printmetadata",
                      "Print the metadata of the gain map of an AVIF file") {
   argparse_.add_argument(arg_input_filename_, "input_filename");
+  arg_jobs_.Init(argparse_);
 }
 
 avifResult PrintMetadataCommand::Run() {
@@ -41,6 +42,7 @@
   if (decoder == nullptr) {
     return AVIF_RESULT_OUT_OF_MEMORY;
   }
+  decoder->maxThreads = arg_jobs_.jobs.value();
 
   avifResult result =
       avifDecoderSetIOFile(decoder.get(), arg_input_filename_.value().c_str());
diff --git a/apps/avifgainmaputil/printmetadata_command.h b/apps/avifgainmaputil/printmetadata_command.h
index 41d3e8e..ac968fb 100644
--- a/apps/avifgainmaputil/printmetadata_command.h
+++ b/apps/avifgainmaputil/printmetadata_command.h
@@ -16,6 +16,7 @@
 
  private:
   argparse::ArgValue<std::string> arg_input_filename_;
+  JobsArgs arg_jobs_;
 };
 
 }  // namespace avif
diff --git a/apps/avifgainmaputil/program_command.cc b/apps/avifgainmaputil/program_command.cc
index 5da3040..ab1c714 100644
--- a/apps/avifgainmaputil/program_command.cc
+++ b/apps/avifgainmaputil/program_command.cc
@@ -2,6 +2,8 @@
 
 #include <vector>
 
+#include "avifutil.h"
+
 namespace avif {
 
 ProgramCommand::ProgramCommand(const std::string& name,
@@ -109,4 +111,28 @@
 
 std::vector<std::string> GridOptionsConverter::default_choices() { return {}; }
 
+argparse::ConvertedValue<int> JobsConverter::from_str(const std::string& str) {
+  argparse::ConvertedValue<int> converted_value;
+
+  if (str == "all") {
+    converted_value.set_value(avifQueryCPUCount());
+  } else {
+    int jobs;
+    try {
+      jobs = std::stoi(str);
+    } catch (...) {
+      converted_value.set_error("Invalid jobs value: " + str);
+      return converted_value;
+    }
+    if (jobs < 1) {
+      jobs = 1;
+    }
+    converted_value.set_value(jobs);
+  }
+
+  return converted_value;
+}
+
+std::vector<std::string> JobsConverter::default_choices() { return {}; }
+
 }  // namespace avif
diff --git a/apps/avifgainmaputil/program_command.h b/apps/avifgainmaputil/program_command.h
index 785479a..b579253 100644
--- a/apps/avifgainmaputil/program_command.h
+++ b/apps/avifgainmaputil/program_command.h
@@ -92,6 +92,13 @@
   std::vector<std::string> default_choices();
 };
 
+// Jobs converter for use with argparse. Parses an integer or "all".
+struct JobsConverter {
+  // Methods expected by argparse.
+  argparse::ConvertedValue<int> from_str(const std::string& str);
+  std::vector<std::string> default_choices();
+};
+
 // Basic flags for image writing.
 struct BasicImageEncodeArgs {
   argparse::ArgValue<int> speed;
@@ -143,6 +150,19 @@
   }
 };
 
+// Flags for setting the number of worker threads.
+struct JobsArgs {
+  argparse::ArgValue<int> jobs;
+
+  void Init(argparse::ArgumentParser& argparse) {
+    argparse.add_argument<int, JobsConverter>(jobs, "--jobs", "-j")
+        .help(
+            "Number of jobs (worker threads), or 'all' to potentially use as "
+            "many cores as possible.")
+        .default_value("all");
+  }
+};
+
 // Helper to parse flags that contain several delimited values.
 template <typename T>
 bool ParseList(std::string to_parse, char delim, int expected_num,
diff --git a/apps/avifgainmaputil/swapbase_command.cc b/apps/avifgainmaputil/swapbase_command.cc
index e6e44ec..cff3f77 100644
--- a/apps/avifgainmaputil/swapbase_command.cc
+++ b/apps/avifgainmaputil/swapbase_command.cc
@@ -151,6 +151,7 @@
       .help(
           "Override content light level information for the alternate image "
           "in the input image, expressed as:  MaxCLL,MaxPALL.");
+  arg_jobs_.Init(argparse_);
 }
 
 avifResult SwapBaseCommand::Run() {
@@ -158,6 +159,7 @@
   if (decoder == nullptr) {
     return AVIF_RESULT_OUT_OF_MEMORY;
   }
+  decoder->maxThreads = arg_jobs_.jobs.value();
   decoder->imageContentToDecode |= AVIF_IMAGE_CONTENT_GAIN_MAP;
   avifResult result = ReadAvif(decoder.get(), arg_input_filename_,
                                arg_image_read_.ignore_profile);
@@ -226,6 +228,8 @@
   encoder->qualityAlpha = arg_image_encode_.quality_alpha;
   encoder->qualityGainMap = arg_gain_map_quality_;
   encoder->speed = arg_image_encode_.speed;
+  encoder->maxThreads = arg_jobs_.jobs.value();
+  encoder->autoTiling = true;  // Match avifenc default.
   result =
       WriteAvifGrid(new_base.get(), arg_image_encode_.grid.value().grid_cols,
                     arg_image_encode_.grid.value().grid_rows, encoder.get(),
diff --git a/apps/avifgainmaputil/swapbase_command.h b/apps/avifgainmaputil/swapbase_command.h
index bffbbdb..73cf5aa 100644
--- a/apps/avifgainmaputil/swapbase_command.h
+++ b/apps/avifgainmaputil/swapbase_command.h
@@ -29,6 +29,7 @@
   ImageReadArgs arg_image_read_;
   BasicImageEncodeArgs arg_image_encode_;
   argparse::ArgValue<int> arg_gain_map_quality_;
+  JobsArgs arg_jobs_;
 };
 
 }  // namespace avif
diff --git a/apps/avifgainmaputil/tonemap_command.cc b/apps/avifgainmaputil/tonemap_command.cc
index 1f2da42..c3057ea 100644
--- a/apps/avifgainmaputil/tonemap_command.cc
+++ b/apps/avifgainmaputil/tonemap_command.cc
@@ -50,6 +50,7 @@
           "MaxCLL,MaxPALL. Only relevant when saving to AVIF.");
   arg_image_read_.Init(argparse_);
   arg_image_encode_.Init(argparse_, /*can_have_alpha=*/true);
+  arg_jobs_.Init(argparse_);
 }
 
 avifResult TonemapCommand::Run() {
@@ -63,6 +64,7 @@
   if (decoder == nullptr) {
     return AVIF_RESULT_OUT_OF_MEMORY;
   }
+  decoder->maxThreads = arg_jobs_.jobs.value();
   decoder->imageContentToDecode |= AVIF_IMAGE_CONTENT_GAIN_MAP;
   avifResult result = ReadAvif(decoder.get(), arg_input_filename_,
                                arg_image_read_.ignore_profile);
@@ -215,7 +217,7 @@
   return WriteImage(tone_mapped.get(), arg_image_encode_.grid.value().grid_cols,
                     arg_image_encode_.grid.value().grid_rows,
                     arg_output_filename_, arg_image_encode_.quality,
-                    arg_image_encode_.speed);
+                    arg_image_encode_.speed, arg_jobs_.jobs.value());
 }
 
 }  // namespace avif
diff --git a/apps/avifgainmaputil/tonemap_command.h b/apps/avifgainmaputil/tonemap_command.h
index 475b0d4..719b134 100644
--- a/apps/avifgainmaputil/tonemap_command.h
+++ b/apps/avifgainmaputil/tonemap_command.h
@@ -23,6 +23,7 @@
   argparse::ArgValue<CicpValues> arg_output_cicp_;
   ImageReadArgs arg_image_read_;
   BasicImageEncodeArgs arg_image_encode_;
+  JobsArgs arg_jobs_;
 };
 
 }  // namespace avif