Coverage Report

Created: 2026-02-14 07:42

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/libjxl/lib/extras/enc/apng.cc
Line
Count
Source
1
// Copyright (c) the JPEG XL Project Authors. All rights reserved.
2
//
3
// Use of this source code is governed by a BSD-style
4
// license that can be found in the LICENSE file.
5
6
// Parts of this code are taken from apngdis, which has the following license:
7
/* APNG Disassembler 2.8
8
 *
9
 * Deconstructs APNG files into individual frames.
10
 *
11
 * http://apngdis.sourceforge.net
12
 *
13
 * Copyright (c) 2010-2015 Max Stepin
14
 * maxst at users.sourceforge.net
15
 *
16
 * zlib license
17
 * ------------
18
 *
19
 * This software is provided 'as-is', without any express or implied
20
 * warranty.  In no event will the authors be held liable for any damages
21
 * arising from the use of this software.
22
 *
23
 * Permission is granted to anyone to use this software for any purpose,
24
 * including commercial applications, and to alter it and redistribute it
25
 * freely, subject to the following restrictions:
26
 *
27
 * 1. The origin of this software must not be misrepresented; you must not
28
 *    claim that you wrote the original software. If you use this software
29
 *    in a product, an acknowledgment in the product documentation would be
30
 *    appreciated but is not required.
31
 * 2. Altered source versions must be plainly marked as such, and must not be
32
 *    misrepresented as being the original software.
33
 * 3. This notice may not be removed or altered from any source distribution.
34
 *
35
 */
36
37
#include "lib/extras/enc/apng.h"
38
39
// IWYU pragma: no_include <pngconf.h>
40
41
#include <memory>
42
43
#include "lib/extras/enc/encode.h"
44
45
#if !JPEGXL_ENABLE_APNG
46
47
namespace jxl {
48
namespace extras {
49
0
std::unique_ptr<Encoder> GetAPNGEncoder() { return nullptr; }
50
}  // namespace extras
51
}  // namespace jxl
52
53
#else  // JPEGXL_ENABLE_APNG
54
55
#include <jxl/codestream_header.h>
56
#include <jxl/color_encoding.h>
57
#include <jxl/types.h>
58
59
#include <cmath>
60
#include <cstdint>
61
#include <cstdio>
62
#include <cstring>
63
#include <string>
64
#include <vector>
65
66
#include "lib/extras/exif.h"
67
#include "lib/extras/packed_image.h"
68
#include "lib/jxl/base/byte_order.h"
69
#include "lib/jxl/base/common.h"
70
#include "lib/jxl/base/compiler_specific.h"
71
#include "lib/jxl/base/data_parallel.h"
72
#include "lib/jxl/base/printf_macros.h"
73
#include "lib/jxl/base/status.h"
74
#include "png.h" /* original (unpatched) libpng is ok */
75
76
namespace jxl {
77
namespace extras {
78
79
namespace {
80
81
constexpr unsigned char kExifSignature[6] = {0x45, 0x78, 0x69,
82
                                             0x66, 0x00, 0x00};
83
84
class APNGEncoder : public Encoder {
85
 public:
86
  std::vector<JxlPixelFormat> AcceptedFormats() const override {
87
    std::vector<JxlPixelFormat> formats;
88
    for (const uint32_t num_channels : {1, 2, 3, 4}) {
89
      for (const JxlDataType data_type :
90
           {JXL_TYPE_UINT8, JXL_TYPE_UINT16, JXL_TYPE_FLOAT}) {
91
        for (JxlEndianness endianness : {JXL_BIG_ENDIAN, JXL_LITTLE_ENDIAN}) {
92
          formats.push_back(
93
              JxlPixelFormat{num_channels, data_type, endianness, /*align=*/0});
94
        }
95
      }
96
    }
97
    return formats;
98
  }
99
  Status Encode(const PackedPixelFile& ppf, EncodedImage* encoded_image,
100
                ThreadPool* pool) const override {
101
    // Encode main image frames
102
    JXL_RETURN_IF_ERROR(VerifyBasicInfo(ppf.info));
103
    encoded_image->icc.clear();
104
    JXL_RETURN_IF_ERROR(
105
        EncodePackedPixelFileToAPNG(ppf, pool, &encoded_image->bitstreams));
106
107
    // Encode extra channels
108
    for (size_t i = 0; i < ppf.extra_channels_info.size(); ++i) {
109
      encoded_image->extra_channel_bitstreams.emplace_back();
110
      auto& ec_bitstreams = encoded_image->extra_channel_bitstreams.back();
111
      JXL_RETURN_IF_ERROR(
112
          EncodePackedPixelFileToAPNG(ppf, pool, &ec_bitstreams, true, i));
113
    }
114
    return true;
115
  }
116
117
 private:
118
  Status EncodePackedPixelFileToAPNG(
119
      const PackedPixelFile& ppf, ThreadPool* pool,
120
      std::vector<std::vector<uint8_t> >* bitstreams,
121
      bool encode_extra_channels = false, size_t extra_channel_index = 0) const;
122
};
123
124
void PngWrite(png_structp png_ptr, png_bytep data, png_size_t length) {
125
  std::vector<uint8_t>* bytes =
126
      static_cast<std::vector<uint8_t>*>(png_get_io_ptr(png_ptr));
127
  bytes->insert(bytes->end(), data, data + length);
128
}
129
130
// Stores XMP and EXIF/IPTC into key/value strings for PNG
131
class BlobsWriterPNG {
132
 public:
133
  static Status Encode(const PackedMetadata& blobs,
134
                       std::vector<std::string>* strings) {
135
    if (!blobs.exif.empty()) {
136
      // PNG viewers typically ignore Exif orientation but not all of them do
137
      // (and e.g. cjxl doesn't), so we overwrite the Exif orientation to the
138
      // identity to avoid repeated orientation.
139
      std::vector<uint8_t> exif = blobs.exif;
140
      ResetExifOrientation(exif);
141
      // By convention, the data is prefixed with "Exif\0\0" when stored in
142
      // the legacy (and non-standard) "Raw profile type exif" text chunk
143
      // currently used here.
144
      // TODO(user): Store Exif data in an eXIf chunk instead, which always
145
      //             begins with the TIFF header.
146
      if (exif.size() >= sizeof kExifSignature &&
147
          memcmp(exif.data(), kExifSignature, sizeof kExifSignature) != 0) {
148
        exif.insert(exif.begin(), kExifSignature,
149
                    kExifSignature + sizeof kExifSignature);
150
      }
151
      JXL_RETURN_IF_ERROR(EncodeBase16("exif", exif, strings));
152
    }
153
    if (!blobs.iptc.empty()) {
154
      JXL_RETURN_IF_ERROR(EncodeBase16("iptc", blobs.iptc, strings));
155
    }
156
    if (!blobs.xmp.empty()) {
157
      // TODO(user): Store XMP data in an "XML:com.adobe.xmp" text chunk
158
      //             instead.
159
      JXL_RETURN_IF_ERROR(EncodeBase16("xmp", blobs.xmp, strings));
160
    }
161
    return true;
162
  }
163
164
 private:
165
  // TODO(eustas): use array
166
  static JXL_INLINE char EncodeNibble(const uint8_t nibble) {
167
    if (nibble < 16) {
168
      return (nibble < 10) ? '0' + nibble : 'a' + nibble - 10;
169
    } else {
170
      JXL_DEBUG_ABORT("Internal logic error");
171
      return 0;
172
    }
173
  }
174
175
  static Status EncodeBase16(const std::string& type,
176
                             const std::vector<uint8_t>& bytes,
177
                             std::vector<std::string>* strings) {
178
    // Encoding: base16 with newline after 72 chars.
179
    const size_t base16_size =
180
        2 * bytes.size() + DivCeil(bytes.size(), static_cast<size_t>(36)) + 1;
181
    std::string base16;
182
    base16.reserve(base16_size);
183
    for (size_t i = 0; i < bytes.size(); ++i) {
184
      if (i % 36 == 0) base16.push_back('\n');
185
      base16.push_back(EncodeNibble(bytes[i] >> 4));
186
      base16.push_back(EncodeNibble(bytes[i] & 0x0F));
187
    }
188
    base16.push_back('\n');
189
    JXL_ENSURE(base16.length() == base16_size);
190
191
    char key[30];
192
    snprintf(key, sizeof(key), "Raw profile type %s", type.c_str());
193
194
    char header[30];
195
    snprintf(header, sizeof(header), "\n%s\n%8" PRIuS, type.c_str(),
196
             bytes.size());
197
198
    strings->emplace_back(key);
199
    strings->push_back(std::string(header) + base16);
200
    return true;
201
  }
202
};
203
204
void MaybeAddCICP(const JxlColorEncoding& c_enc, png_structp png_ptr,
205
                  png_infop info_ptr) {
206
  png_byte cicp_data[4] = {};
207
  png_unknown_chunk cicp_chunk;
208
  if (c_enc.color_space != JXL_COLOR_SPACE_RGB) {
209
    return;
210
  }
211
  if (c_enc.primaries == JXL_PRIMARIES_P3) {
212
    if (c_enc.white_point == JXL_WHITE_POINT_D65) {
213
      cicp_data[0] = 12;
214
    } else if (c_enc.white_point == JXL_WHITE_POINT_DCI) {
215
      cicp_data[0] = 11;
216
    } else {
217
      return;
218
    }
219
  } else if (c_enc.primaries != JXL_PRIMARIES_CUSTOM &&
220
             c_enc.white_point == JXL_WHITE_POINT_D65) {
221
    cicp_data[0] = static_cast<png_byte>(c_enc.primaries);
222
  } else {
223
    return;
224
  }
225
  if (c_enc.transfer_function == JXL_TRANSFER_FUNCTION_UNKNOWN ||
226
      c_enc.transfer_function == JXL_TRANSFER_FUNCTION_GAMMA) {
227
    return;
228
  }
229
  cicp_data[1] = static_cast<png_byte>(c_enc.transfer_function);
230
  cicp_data[2] = 0;
231
  cicp_data[3] = 1;
232
  cicp_chunk.data = cicp_data;
233
  cicp_chunk.size = sizeof(cicp_data);
234
  cicp_chunk.location = PNG_HAVE_IHDR;
235
  memcpy(cicp_chunk.name, "cICP", 5);
236
  png_set_keep_unknown_chunks(png_ptr, PNG_HANDLE_CHUNK_ALWAYS,
237
                              reinterpret_cast<const png_byte*>("cICP"), 1);
238
  png_set_unknown_chunks(png_ptr, info_ptr, &cicp_chunk, 1);
239
}
240
241
bool MaybeAddSRGB(const JxlColorEncoding& c_enc, png_structp png_ptr,
242
                  png_infop info_ptr) {
243
  if (c_enc.transfer_function == JXL_TRANSFER_FUNCTION_SRGB &&
244
      (c_enc.color_space == JXL_COLOR_SPACE_GRAY ||
245
       (c_enc.color_space == JXL_COLOR_SPACE_RGB &&
246
        c_enc.primaries == JXL_PRIMARIES_SRGB &&
247
        c_enc.white_point == JXL_WHITE_POINT_D65))) {
248
    png_set_sRGB(png_ptr, info_ptr, c_enc.rendering_intent);
249
    png_set_cHRM_fixed(png_ptr, info_ptr, 31270, 32900, 64000, 33000, 30000,
250
                       60000, 15000, 6000);
251
    png_set_gAMA_fixed(png_ptr, info_ptr, 45455);
252
    return true;
253
  }
254
  return false;
255
}
256
257
void MaybeAddCHRM(const JxlColorEncoding& c_enc, png_structp png_ptr,
258
                  png_infop info_ptr) {
259
  if (c_enc.color_space != JXL_COLOR_SPACE_RGB) return;
260
  if (c_enc.primaries == 0) return;
261
  png_set_cHRM(png_ptr, info_ptr, c_enc.white_point_xy[0],
262
               c_enc.white_point_xy[1], c_enc.primaries_red_xy[0],
263
               c_enc.primaries_red_xy[1], c_enc.primaries_green_xy[0],
264
               c_enc.primaries_green_xy[1], c_enc.primaries_blue_xy[0],
265
               c_enc.primaries_blue_xy[1]);
266
}
267
268
void MaybeAddGAMA(const JxlColorEncoding& c_enc, png_structp png_ptr,
269
                  png_infop info_ptr) {
270
  switch (c_enc.transfer_function) {
271
    case JXL_TRANSFER_FUNCTION_LINEAR:
272
      png_set_gAMA_fixed(png_ptr, info_ptr, PNG_FP_1);
273
      break;
274
    case JXL_TRANSFER_FUNCTION_SRGB:
275
      png_set_gAMA_fixed(png_ptr, info_ptr, 45455);
276
      break;
277
    case JXL_TRANSFER_FUNCTION_GAMMA:
278
      png_set_gAMA(png_ptr, info_ptr, c_enc.gamma);
279
      break;
280
281
    default:;
282
      // No gAMA chunk.
283
  }
284
}
285
286
void MaybeAddCLLi(const JxlColorEncoding& c_enc, const float intensity_target,
287
                  png_structp png_ptr, png_infop info_ptr) {
288
  if (c_enc.transfer_function != JXL_TRANSFER_FUNCTION_PQ) return;
289
  if (intensity_target == 10000) return;
290
291
  const uint32_t max_content_light_level =
292
      static_cast<uint32_t>(10000.f * Clamp1(intensity_target, 0.f, 10000.f));
293
  png_byte chunk_data[8] = {};
294
  png_save_uint_32(chunk_data, max_content_light_level);
295
  // Leave MaxFALL set to 0.
296
  png_unknown_chunk chunk;
297
  memcpy(chunk.name, "cLLI", 5);
298
  chunk.data = chunk_data;
299
  chunk.size = sizeof chunk_data;
300
  chunk.location = PNG_HAVE_IHDR;
301
  png_set_keep_unknown_chunks(png_ptr, PNG_HANDLE_CHUNK_ALWAYS,
302
                              reinterpret_cast<const png_byte*>("cLLI"), 1);
303
  png_set_unknown_chunks(png_ptr, info_ptr, &chunk, 1);
304
}
305
306
Status APNGEncoder::EncodePackedPixelFileToAPNG(
307
    const PackedPixelFile& ppf, ThreadPool* pool,
308
    std::vector<std::vector<uint8_t> >* bitstreams, bool encode_extra_channels,
309
    size_t extra_channel_index) const {
310
  JxlExtraChannelInfo ec_info{};
311
  if (encode_extra_channels) {
312
    if (ppf.extra_channels_info.size() <= extra_channel_index) {
313
      return JXL_FAILURE("Invalid index for extra channel");
314
    }
315
    ec_info = ppf.extra_channels_info[extra_channel_index].ec_info;
316
  }
317
318
  bool has_alpha = !encode_extra_channels && (ppf.info.alpha_bits != 0);
319
  bool is_gray = encode_extra_channels || (ppf.info.num_color_channels == 1);
320
  size_t color_channels =
321
      encode_extra_channels ? 1 : ppf.info.num_color_channels;
322
  size_t num_channels = color_channels + (has_alpha ? 1 : 0);
323
324
  bitstreams->emplace_back();
325
  auto bytes = &bitstreams->back();
326
327
  size_t count = 0;
328
  size_t anim_chunks = 0;
329
330
  for (const auto& frame : ppf.frames) {
331
    if (!ppf.info.have_animation && count > 0) {
332
      // in the case of non-coalesced layers (composite still), write multiple
333
      // files
334
      bitstreams->emplace_back();
335
      bytes = &bitstreams->back();
336
    }
337
    const PackedImage& color = encode_extra_channels
338
                                   ? frame.extra_channels[extra_channel_index]
339
                                   : frame.color;
340
341
    size_t xsize = color.xsize;
342
    size_t ysize = color.ysize;
343
    size_t num_samples = num_channels * xsize * ysize;
344
345
    uint32_t bits_per_sample = encode_extra_channels ? ec_info.bits_per_sample
346
                                                     : ppf.info.bits_per_sample;
347
    if (!encode_extra_channels) {
348
      JXL_RETURN_IF_ERROR(VerifyPackedImage(color, ppf.info));
349
    } else {
350
      JXL_RETURN_IF_ERROR(VerifyFormat(color.format));
351
      JXL_RETURN_IF_ERROR(VerifyBitDepth(color.format.data_type,
352
                                         bits_per_sample,
353
                                         ec_info.exponent_bits_per_sample));
354
    }
355
    const JxlPixelFormat format = color.format;
356
    const uint8_t* in = reinterpret_cast<const uint8_t*>(color.pixels());
357
    JXL_RETURN_IF_ERROR(PackedImage::ValidateDataType(format.data_type));
358
    size_t data_bits_per_sample = PackedImage::BitsPerChannel(format.data_type);
359
    size_t bytes_per_sample = data_bits_per_sample / 8;
360
    size_t out_bytes_per_sample = bytes_per_sample > 1 ? 2 : 1;
361
    size_t out_stride = xsize * num_channels * out_bytes_per_sample;
362
    size_t out_size = ysize * out_stride;
363
    std::vector<uint8_t> out(out_size);
364
365
    if (format.data_type == JXL_TYPE_UINT8) {
366
      if (bits_per_sample < 8) {
367
        float mul = 255.0 / ((1u << bits_per_sample) - 1);
368
        for (size_t i = 0; i < num_samples; ++i) {
369
          out[i] = static_cast<uint8_t>(std::lround(in[i] * mul));
370
        }
371
      } else {
372
        memcpy(out.data(), in, out_size);
373
      }
374
    } else if (format.data_type == JXL_TYPE_UINT16) {
375
      if (bits_per_sample < 16 || format.endianness != JXL_BIG_ENDIAN) {
376
        float mul = 65535.0 / ((1u << bits_per_sample) - 1);
377
        const uint8_t* p_in = in;
378
        uint8_t* p_out = out.data();
379
        for (size_t i = 0; i < num_samples; ++i, p_in += 2, p_out += 2) {
380
          uint32_t val = (format.endianness == JXL_BIG_ENDIAN ? LoadBE16(p_in)
381
                                                              : LoadLE16(p_in));
382
          StoreBE16(static_cast<uint32_t>(std::lround(val * mul)), p_out);
383
        }
384
      } else {
385
        memcpy(out.data(), in, out_size);
386
      }
387
    } else if (format.data_type == JXL_TYPE_FLOAT) {
388
      constexpr float kMul = 65535.0;
389
      const uint8_t* p_in = in;
390
      uint8_t* p_out = out.data();
391
      for (size_t i = 0; i < num_samples;
392
           ++i, p_in += sizeof(float), p_out += 2) {
393
        float val =
394
            Clamp1(format.endianness == JXL_BIG_ENDIAN ? LoadBEFloat(p_in)
395
                   : format.endianness == JXL_LITTLE_ENDIAN
396
                       ? LoadLEFloat(p_in)
397
                       : *reinterpret_cast<const float*>(p_in),
398
                   0.f, 1.f);
399
        StoreBE16(static_cast<uint32_t>(std::lround(val * kMul)), p_out);
400
      }
401
    }
402
    png_structp png_ptr;
403
    png_infop info_ptr;
404
405
    png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr,
406
                                      nullptr);
407
408
    if (!png_ptr) return JXL_FAILURE("Could not init png encoder");
409
410
    info_ptr = png_create_info_struct(png_ptr);
411
    if (!info_ptr) return JXL_FAILURE("Could not init png info struct");
412
    png_set_compression_level(png_ptr, 1);
413
414
    png_set_write_fn(png_ptr, bytes, PngWrite, nullptr);
415
    png_set_flush(png_ptr, 0);
416
417
    int width = xsize;
418
    int height = ysize;
419
420
    png_byte color_type = (is_gray ? PNG_COLOR_TYPE_GRAY : PNG_COLOR_TYPE_RGB);
421
    if (has_alpha) color_type |= PNG_COLOR_MASK_ALPHA;
422
    png_byte bit_depth = out_bytes_per_sample * 8;
423
424
    png_set_IHDR(png_ptr, info_ptr, width, height, bit_depth, color_type,
425
                 PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_BASE,
426
                 PNG_FILTER_TYPE_BASE);
427
    if (count == 0 || !ppf.info.have_animation) {
428
      if (!encode_extra_channels) {
429
        if (!MaybeAddSRGB(ppf.color_encoding, png_ptr, info_ptr)) {
430
          if (ppf.primary_color_representation !=
431
              PackedPixelFile::kIccIsPrimary) {
432
            MaybeAddCICP(ppf.color_encoding, png_ptr, info_ptr);
433
          }
434
          if (!ppf.icc.empty()) {
435
            png_set_benign_errors(png_ptr, 1);
436
            png_set_iCCP(png_ptr, info_ptr, "1", 0, ppf.icc.data(),
437
                         ppf.icc.size());
438
          }
439
          MaybeAddCHRM(ppf.color_encoding, png_ptr, info_ptr);
440
          MaybeAddGAMA(ppf.color_encoding, png_ptr, info_ptr);
441
        }
442
        MaybeAddCLLi(ppf.color_encoding, ppf.info.intensity_target, png_ptr,
443
                     info_ptr);
444
445
        std::vector<std::string> textstrings;
446
        JXL_RETURN_IF_ERROR(BlobsWriterPNG::Encode(ppf.metadata, &textstrings));
447
        for (size_t kk = 0; kk + 1 < textstrings.size(); kk += 2) {
448
          png_text text;
449
          text.key = const_cast<png_charp>(textstrings[kk].c_str());
450
          text.text = const_cast<png_charp>(textstrings[kk + 1].c_str());
451
          text.compression = PNG_TEXT_COMPRESSION_zTXt;
452
          png_set_text(png_ptr, info_ptr, &text, 1);
453
        }
454
      }
455
456
      png_write_info(png_ptr, info_ptr);
457
    } else {
458
      // fake writing a header, otherwise libpng gets confused
459
      size_t pos = bytes->size();
460
      png_write_info(png_ptr, info_ptr);
461
      bytes->resize(pos);
462
    }
463
464
    if (ppf.info.have_animation) {
465
      if (count == 0) {
466
        png_byte adata[8];
467
        png_save_uint_32(adata, ppf.frames.size());
468
        png_save_uint_32(adata + 4, ppf.info.animation.num_loops);
469
        png_byte actl[5] = "acTL";
470
        png_write_chunk(png_ptr, actl, adata, 8);
471
      }
472
      png_byte fdata[26];
473
      // TODO(jon): also make this work for the non-coalesced case
474
      png_save_uint_32(fdata, anim_chunks++);
475
      png_save_uint_32(fdata + 4, width);
476
      png_save_uint_32(fdata + 8, height);
477
      png_save_uint_32(fdata + 12, 0);
478
      png_save_uint_32(fdata + 16, 0);
479
      png_save_uint_16(fdata + 20, frame.frame_info.duration *
480
                                       ppf.info.animation.tps_denominator);
481
      png_save_uint_16(fdata + 22, ppf.info.animation.tps_numerator);
482
      fdata[24] = 1;
483
      fdata[25] = 0;
484
      png_byte fctl[5] = "fcTL";
485
      png_write_chunk(png_ptr, fctl, fdata, 26);
486
    }
487
488
    std::vector<uint8_t*> rows(height);
489
    for (int y = 0; y < height; ++y) {
490
      rows[y] = out.data() + y * out_stride;
491
    }
492
493
    png_write_flush(png_ptr);
494
    const size_t pos = bytes->size();
495
    png_write_image(png_ptr, rows.data());
496
    png_write_flush(png_ptr);
497
    if (count > 0 && ppf.info.have_animation) {
498
      std::vector<uint8_t> fdata(4);
499
      png_save_uint_32(fdata.data(), anim_chunks++);
500
      size_t p = pos;
501
      while (p + 8 < bytes->size()) {
502
        size_t len = png_get_uint_32(bytes->data() + p);
503
        JXL_ENSURE(bytes->operator[](p + 4) == 'I');
504
        JXL_ENSURE(bytes->operator[](p + 5) == 'D');
505
        JXL_ENSURE(bytes->operator[](p + 6) == 'A');
506
        JXL_ENSURE(bytes->operator[](p + 7) == 'T');
507
        fdata.insert(fdata.end(), bytes->data() + p + 8,
508
                     bytes->data() + p + 8 + len);
509
        p += len + 12;
510
      }
511
      bytes->resize(pos);
512
513
      png_byte fdat[5] = "fdAT";
514
      png_write_chunk(png_ptr, fdat, fdata.data(), fdata.size());
515
    }
516
517
    count++;
518
    if (count == ppf.frames.size() || !ppf.info.have_animation) {
519
      png_write_end(png_ptr, nullptr);
520
    }
521
522
    png_destroy_write_struct(&png_ptr, &info_ptr);
523
  }
524
525
  return true;
526
}
527
528
}  // namespace
529
530
std::unique_ptr<Encoder> GetAPNGEncoder() {
531
  return jxl::make_unique<APNGEncoder>();
532
}
533
534
}  // namespace extras
535
}  // namespace jxl
536
537
#endif  // JPEGXL_ENABLE_APNG