JPEG XL animation を実装してみた

次世代画像フォーマットJPEG XLは、JPEGと違ってアニメーションもアルファチャンネルもサポートしています。つまりこれひとつでJPEG/PNG/GIFがカバーしている領域を奪えるっていう野心的なフォーマットですね。これに乗らない手はない！ってことで、WebKit に [JPEG XL decoderを追加](https://github.com/WebKit/WebKit/commit/ef2a73db3c490c990a39f0bfed21daf2b2717d41
)してみました。まだアニメーションには未対応なので、どうやってアニメーションさせればいいかを見てみようと思います。

# libjxl
JPEG XLのデコードには [libjxl](https://github.com/libjxl/libjxl) ライブラリを使用します。libjxl API の設計として
1. (部分)データをデコーダにセットする
2. セットしたデータを処理しろと指示する。
ということの繰り返しでデコードが進むようになっています。

実際のAPIで言うと以下のような流れになります。

```
JxlDecoder＊dec = JxlDecoderCreate(nullptr);
JxlDecoderSetInput(dec, data, datasize); // 入力データのセット
while (true) {
  JxlDecoderStatus status = JxlDecoderProcessInput(dec);
  if (status == JXL_DEC_ERROR || status == JXL_DEC_SUCCESS || status == JXL_DEC_NEED_MORE_INPUT) {
    // JXL_DEC_ERRORかJXL_DEC_SUCCESSの場合はデコード終了。
    // JXL_DEC_NEED_MORE_INPUT の場合は後続データ待ち。
    break;
  }
  // status に応じた処理
} 
size_t remaining = JxlDecoderReleaseInput(dec);
// JxlDecoderReleaseInput()を呼ぶと処理しきれなかったデータサイズが返るので、
// JXL_DEC_NEED_MORE_INPUTの場合は、残データと後続データを結合して、
// 再度 JxlDecoderSetInput()から繰り返す。
```

`JxlDecoderProcessInput()` は区切りのいいところで中断させることができます。例えば画像サイズが確定したところでいったん`JxlDecoderProcessInput()` を抜けてほしい場合は、

```
JxlDecoderSubscribeEvents(dec, JXL_DEC_BASIC_INFO);
```

とすると、画像の基本情報(BasicInfo)がパースできた段階で `JxlDecoderProcessInput()` が終了し、`JXL_DEC_BASIC_INFO` が関数から返されます。このタイミングでBasicInfoが取得できるようになっているので、`JxlDecoderGetBasicInfo()`を使って取得したりします。

## フレームのデコード
アニメーションの場合は複数のフレームがJPEG XLに格納されています。静止画の場合はフレームは一つです。デコーダがフレームを検出すると、`JxlDecoderProcessInput()`が`JXL_DEC_FRAME` を返します。(前もってSubscribeする必要があります)
このタイミングで `JxlDecoderSetImageOutCallback()` を使ってコールバックを登録すると、実際のピクセルデータが得られたタイミングでデコーダがコールバックを呼び出し、ピクセルデータが渡されます。コールバックの形式は

```
typedef void (*JxlImageOutCallback)(void* opaque, size_t x, size_t y,
                                    size_t num_pixels, const void* pixels);
```
のようになっています。

一つのフレームの処理が完了すると、`JXL_DEC_FULL_IMAGE` が`JxlDecoderProcessInput()`から返ります。

フレームのデコード処理をまとめると
1. JXL_DEC_FRAME: フレーム開始
2. ImageOutCallback 経由でピクセルデータ回収
3. JXL_DEC_FULL_IMAGE: フレーム完了
となります。

# アニメーション
上記流れでフレーム画像自体は取得できるのですが、実際のアニメーションにあたっては時間軸の情報が必要です。
まずBasicInfoには

```
  /** Indicates animation frames exist in the codestream. The animation
   * information is not included in the basic info.
   */
  JXL_BOOL have_animation;
```
とアニメーションがあるかないかのフラグがありますね。さらに、これがTRUEの場合は

```
  /** Animation header with global animation properties for all frames, only
   * used if have_animation is JXL_TRUE.
   */
  JxlAnimationHeader animation;
```
とAnimationHeaderもBasicInfoに設定されます。

AnimationHeaderは

```
/** The codestream animation header, optionally present in the beginning of
 * the codestream, and if it is it applies to all animation frames, unlike
 * JxlFrameHeader which applies to an individual frame.
 */
typedef struct {
  /** Numerator of ticks per second of a single animation frame time unit */
  uint32_t tps_numerator;

  /** Denominator of ticks per second of a single animation frame time unit */
  uint32_t tps_denominator;

  /** Amount of animation loops, or 0 to repeat infinitely */
  uint32_t num_loops;

  /** Whether animation time codes are present at animation frames in the
   * codestream */
  JXL_BOOL have_timecodes;
} JxlAnimationHeader;
```

となっています。こちらはグローバルな設定のようです。

JXL_DEC_FRAME の説明は

```
  /** Informative event by JxlDecoderProcessInput: Beginning of a frame.
   * JxlDecoderGetFrameHeader can be used at this point. A note on frames:
   * a JPEG XL image can have internal frames that are not intended to be
   * displayed (e.g. used for compositing a final frame), but this only returns
   * displayed frames. A displayed frame either has an animation duration or is
   * the only or last frame in the image. This event occurs max once per
   * displayed frame, always later than JXL_DEC_COLOR_ENCODING, and always
   * earlier than any pixel data. While JPEG XL supports encoding a single frame
   * as the composition of multiple internal sub-frames also called frames, this
   * event is not indicated for the internal frames.
   */
  JXL_DEC_FRAME = 0x400,
```
となっています。
> A displayed frame either has an animation duration or isthe only or last frame in the image.
と言っているので、最終フレーム以外には animation duration が設定されているようです。

フレーム情報は `JxlDecoderGetFrameHeader()` として取得可能です。

```
/** The header of one displayed frame. */
typedef struct {
  /** How long to wait after rendering in ticks. The duration in seconds of a
   * tick is given by tps_numerator and tps_denominator in JxlAnimationHeader.
   */
  uint32_t duration;

  /** SMPTE timecode of the current frame in form 0xHHMMSSFF, or 0. The bits are
   * interpreted from most-significant to least-significant as hour, minute,
   * second, and frame. If timecode is nonzero, it is strictly larger than that
   * of a previous frame with nonzero duration. These values are only available
   * if have_timecodes in JxlAnimationHeader is JXL_TRUE.
   * This value is only used if have_timecodes in JxlAnimationHeader is
   * JXL_TRUE.
   */
  uint32_t timecode;

  /** Length of the frame name in bytes, or 0 if no name.
   * Excludes null termination character.
   */
  uint32_t name_length;

  /** Indicates this is the last animation frame.
   */
  JXL_BOOL is_last;
} JxlFrameHeader;
```

frame の duration は tick 単位で表現され、それが何秒にあたるか(ticks per second) はグローバルなAnimationHeaderを参照しろってことですね。

そして [SMPTE timecode](https://en.wikipedia.org/wiki/SMPTE_timecode) なるものが出てきました。がオプショナルっぽいので無視しておきます。

では実データで見てみましょう。conformance testから[animation_icos4d](https://github.com/libjxl/conformance/blob/master/testcases/animation_icos4d/) のinput.jxl を使ってみます。
![ref.apng](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1410370/e90bc44b-6854-681f-0a9b-813653b653a3.png)

BasicInfo は

```
num_loops = 0
tps_numerator = 1000
tps_denominator = 1
```

となっています。 num_loops = 0 なので、無限ループです。
tick per sec は tps_numerator / tps_denominator = 1000 になります。

各フレームは

```
duration = 50, is_last = 0
duration = 50, is_last = 0
...
duration = 50, is_last = 0
duration = 50, is_last = 1
```

と、すべて duration = 50 ticks となっています。ですので、フレーム当たり 50 / 1000 sec = 50 ms でアニメーションすることになります。
また、is_last フラグは最終フレームのみセットされていますね。

メタデータとしてフレーム数情報が取得できないようなので、事前にフレーム数が必要な場合は1パス目でフレームヘッダだけ読んでいく、といった処理が必要になってくるのかもしれません。

フレームの読み飛ばしは`JxlDecoderSkipFrames()`
で行えます。また、先頭に巻き戻すには`JxlDecoderRewind()`が使えます。`JxlDecoderSkipFrames()`はキャッシュを使っているようで、初回走査時はデコードしていく場合と速度的には大差がないようです。

# WebKit 実装
といったあたりを踏まえてWebKit側で対応をいれました！
[Support Animated JPEG-XL images](https://github.com/WebKit/WebKit/commit/3cddf74bb622d2cad2bd2220cd1f3b9cdab3dfd7)