[PR] avcodec/avformat: add MobiClip encoding and Wii MOC5 support (PR #23974)

MrKev312 via ffmpeg-devel <[email protected]> Fri, 31 Jul 2026 19:43:10 -0000
Newsgroups gmane.comp.video.ffmpeg.devel
Message-ID <178552699110.59.2145595053460102796@29965ddac10e>
PR #23974 opened by MrKev312
URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/23974
Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/23974.patch

This pull request adds end-to-end support for producing and processing the Wii MOC5 variant of MobiClip, building on FFmpeg's existing MobiClip decoder.

The series:

- factors tables and transform code shared with the existing decoder into a common MobiClip module;
- exposes the MOC5 and Moflex bitstream profiles;
- adds an encoder and decoder for the Wii MobiClip IMA ADPCM variant;
- adds a demuxer and muxer for the Wii MOC5 container;
- adds a MobiClip video encoder supporting both the MOC5 and Moflex profiles;
- implements intra and inter prediction, up to five reference frames, full-pel and half-pel motion search, and recursive partitioning;
- supports fixed-quantizer, one-pass, and two-pass rate control;
- supports optional deterministic temporal-range parallelism;
- adds documentation, API and version updates, generated FATE coverage, and maintainer entries.

The MOC5 muxer accepts an optional caller-supplied 160-byte certificate. No certificate or copyrighted game data is included in the pull request.

Architecture-specific x86 optimizations are intentionally left for a separate follow-up pull request.

## Compatibility testing

Files produced by the encoder and muxer were successfully decoded using FFmpeg's existing MobiClip decoder.

The resulting MOC5 files were also tested on real Wii hardware with:

- Just Dance 2
- Just Dance 3
- Just Dance 4

## Automated testing

The implementation was built and tested using MSYS2 UCRT64 on Windows.

The following completed successfully:

- a clean configure and full build;
- all 25 MobiClip FATE tests;
- `git diff --check`.

I am prepared to maintain the MobiClip codec and MOC5 container support covered by this series, including the existing decoder.


>From 379c4612d39338df9f378cda01a31e3728aa7d90 Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Thu, 30 Jul 2026 23:58:47 +0200
Subject: [PATCH 01/10] avcodec/mobiclip: factor shared codec data out of the
 decoder

The encoder needs several tables and the four-point inverse transform currently private to the existing decoder.

Move the common 4x4 scan and quantization data, coefficient masks, run-level tables, and transform helper into a shared MobiClip module. Update the decoder to consume the shared definitions without changing decoded output.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 libavcodec/Makefile       |   2 +-
 libavcodec/mobiclip.c     | 126 ++++++--------------------------------
 libavcodec/mobiclipdata.c | 103 +++++++++++++++++++++++++++++++
 libavcodec/mobiclipdata.h |  54 ++++++++++++++++
 4 files changed, 178 insertions(+), 107 deletions(-)
 create mode 100644 libavcodec/mobiclipdata.c
 create mode 100644 libavcodec/mobiclipdata.h

diff --git a/libavcodec/Makefile b/libavcodec/Makefile
index e464811af6..4cf9d57b89 100644
--- a/libavcodec/Makefile
+++ b/libavcodec/Makefile
@@ -545,7 +545,7 @@ OBJS-$(CONFIG_MJPEG_VAAPI_ENCODER)     += vaapi_encode_mjpeg.o
 OBJS-$(CONFIG_MLP_DECODER)             += mlpdec.o mlpdsp.o
 OBJS-$(CONFIG_MLP_ENCODER)             += mlpenc.o mlp.o
 OBJS-$(CONFIG_MMVIDEO_DECODER)         += mmvideo.o
-OBJS-$(CONFIG_MOBICLIP_DECODER)        += mobiclip.o
+OBJS-$(CONFIG_MOBICLIP_DECODER)        += mobiclip.o mobiclipdata.o
 OBJS-$(CONFIG_MOTIONPIXELS_DECODER)    += motionpixels.o
 OBJS-$(CONFIG_MOVTEXT_DECODER)         += movtextdec.o ass.o
 OBJS-$(CONFIG_MOVTEXT_ENCODER)         += movtextenc.o ass_split.o
diff --git a/libavcodec/mobiclip.c b/libavcodec/mobiclip.c
index 8e8fe44800..a5f611b248 100644
--- a/libavcodec/mobiclip.c
+++ b/libavcodec/mobiclip.c
@@ -35,26 +35,11 @@
 #include "get_bits.h"
 #include "golomb.h"
 #include "mathops.h"
+#include "mobiclipdata.h"
 
 #define MOBI_RL_VLC_BITS 12
 #define MOBI_MV_VLC_BITS 6
 
-static const uint8_t zigzag4x4_tab[] =
-{
-    0x00, 0x04, 0x01, 0x02, 0x05, 0x08, 0x0C, 0x09, 0x06, 0x03, 0x07, 0x0A,
-    0x0D, 0x0E, 0x0B, 0x0F
-};
-
-static const uint8_t quant4x4_tab[][16] =
-{
-    { 10, 13, 13, 10, 16, 10, 13, 13, 13, 13, 16, 10, 16, 13, 13, 16 },
-    { 11, 14, 14, 11, 18, 11, 14, 14, 14, 14, 18, 11, 18, 14, 14, 18 },
-    { 13, 16, 16, 13, 20, 13, 16, 16, 16, 16, 20, 13, 20, 16, 16, 20 },
-    { 14, 18, 18, 14, 23, 14, 18, 18, 18, 18, 23, 14, 23, 18, 18, 23 },
-    { 16, 20, 20, 16, 25, 16, 20, 20, 20, 20, 25, 16, 25, 20, 20, 25 },
-    { 18, 23, 23, 18, 29, 18, 23, 23, 23, 23, 29, 18, 29, 23, 23, 29 },
-};
-
 static const uint8_t quant8x8_tab[][64] =
 {
     { 20, 19, 19, 25, 18, 25, 19, 24, 24, 19, 20, 18, 32, 18, 20, 19, 19, 24, 24, 19, 19, 25, 18, 25, 18, 25, 18, 25, 19, 24, 24, 19,
@@ -71,34 +56,6 @@ static const uint8_t quant8x8_tab[][64] =
       34, 43, 43, 34, 32, 58, 32, 36, 32, 58, 32, 43, 43, 34, 34, 43, 43, 32, 46, 32, 46, 32, 34, 43, 43, 34, 32, 58, 32, 43, 43, 32,},
 };
 
-static const uint8_t block4x4_coefficients_tab[] =
-{
-    15, 0, 2, 1, 4, 8, 12, 3, 11, 13, 14, 7, 10, 5, 9, 6,
-};
-
-static const uint8_t pframe_block4x4_coefficients_tab[] =
-{
-    0, 4, 1, 8, 2, 12, 3, 5, 10, 15, 7, 13, 14, 11, 9, 6,
-};
-
-static const uint8_t block8x8_coefficients_tab[] =
-{
-    0x00, 0x1F, 0x3F, 0x0F, 0x08, 0x04, 0x02, 0x01, 0x0B, 0x0E, 0x1B, 0x0D,
-    0x03, 0x07, 0x0C, 0x17, 0x1D, 0x0A, 0x1E, 0x05, 0x10, 0x2F, 0x37, 0x3B,
-    0x13, 0x3D, 0x3E, 0x09, 0x1C, 0x06, 0x15, 0x1A, 0x33, 0x11, 0x12, 0x14,
-    0x18, 0x20, 0x3C, 0x35, 0x19, 0x16, 0x3A, 0x30, 0x31, 0x32, 0x27, 0x34,
-    0x2B, 0x2D, 0x39, 0x38, 0x23, 0x36, 0x2E, 0x21, 0x25, 0x22, 0x24, 0x2C,
-    0x2A, 0x28, 0x29, 0x26,
-};
-
-static const uint8_t pframe_block8x8_coefficients_tab[] =
-{
-    0x00, 0x0F, 0x04, 0x01, 0x08, 0x02, 0x0C, 0x03, 0x05, 0x0A, 0x0D, 0x07, 0x0E, 0x0B, 0x1F, 0x09,
-    0x06, 0x10, 0x3F, 0x1E, 0x17, 0x1D, 0x1B, 0x1C, 0x13, 0x18, 0x1A, 0x12, 0x11, 0x14, 0x15, 0x20,
-    0x2F, 0x16, 0x19, 0x37, 0x3D, 0x3E, 0x3B, 0x3C, 0x33, 0x35, 0x21, 0x24, 0x22, 0x28, 0x23, 0x2C,
-    0x30, 0x27, 0x2D, 0x25, 0x3A, 0x2B, 0x2E, 0x2A, 0x31, 0x34, 0x38, 0x32, 0x29, 0x26, 0x39, 0x36
-};
-
 static const uint8_t run_residue[2][256] =
 {
     {
@@ -123,40 +80,6 @@ static const uint8_t run_residue[2][256] =
     },
 };
 
-static const uint8_t bits0[] = {
-     9, 11, 11, 11, 11, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
-    10, 10, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 12,
-    12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,  7, 10, 10,  9,
-     9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,
-     9,  9,  9,  9,  9,  8,  8,  8,  8,  8,  8,  8,  8,  8,  8,
-     8,  8,  8,  7,  7,  7,  7,  7,  7,  7,  7,  6,  6,  6,  6,
-     6,  6,  6,  6,  6,  6,  5,  5,  5,  4,  2,  3,  4,  4,
-};
-
-static const uint16_t syms0[] = {
-    0x0, 0x822, 0x803, 0xB, 0xA, 0xB81, 0xB61, 0xB41, 0xB21, 0x122,
-    0x102, 0xE2, 0xC2, 0xA2, 0x63, 0x43, 0x24, 0xC, 0x25, 0x2E1, 0x301,
-    0xBA1, 0xBC1, 0xBE1, 0xC01, 0x26, 0x44, 0x83, 0xA3, 0xC3, 0x142,
-    0x321, 0x341, 0xC21, 0xC41, 0xC61, 0xC81, 0xCA1, 0xCC1, 0xCE1, 0xD01,
-    0x0, 0x9, 0x8, 0xB01, 0xAE1, 0xAC1, 0xAA1, 0xA81, 0xA61, 0xA41, 0xA21,
-    0x802, 0x2C1, 0x2A1, 0x281, 0x261, 0x241, 0x221, 0x201, 0x1E1, 0x82,
-    0x62, 0x7, 0x6, 0xA01, 0x9E1, 0x9C1, 0x9A1, 0x981, 0x961, 0x941, 0x921,
-    0x1C1, 0x1A1, 0x42, 0x23, 0x5, 0x901, 0x8E1, 0x8C1, 0x8A1, 0x181, 0x161,
-    0x141, 0x4, 0x881, 0x861, 0x841, 0x821, 0x121, 0x101, 0xE1, 0xC1, 0x22,
-    0x3, 0xA1, 0x81, 0x61, 0x801, 0x1, 0x21, 0x41, 0x2,
-};
-
-static const uint16_t syms1[] = {
-    0x0, 0x807, 0x806, 0x16, 0x15, 0x842, 0x823, 0x805, 0x1A1, 0xA3, 0x102, 0x83,
-    0x64, 0x44, 0x27, 0x14, 0x13, 0x17, 0x18, 0x28, 0x122, 0x862, 0x882, 0x9E1, 0xA01,
-    0x19, 0x1A, 0x1B, 0x29, 0xC3, 0x2A, 0x45, 0xE3, 0x1C1, 0x808, 0x8A2, 0x8C2, 0xA21,
-    0xA41, 0xA61, 0xA81, 0x0, 0x12, 0x11, 0x9C1, 0x9A1, 0x981, 0x961, 0x941, 0x822, 0x804,
-    0x181, 0x161, 0xE2, 0xC2, 0xA2, 0x63, 0x43, 0x26, 0x25, 0x10, 0x82, 0xF, 0xE, 0xD, 0x901,
-    0x8E1, 0x8C1, 0x803, 0x141, 0x121, 0x101, 0x921, 0x62, 0x24, 0xC, 0xB, 0xA, 0x881, 0x861,
-    0xC1, 0x8A1, 0xE1, 0x42, 0x23, 0x9, 0x802, 0xA1, 0x841, 0x821, 0x81, 0x61, 0x8, 0x7, 0x22,
-    0x6, 0x41, 0x5, 0x4, 0x801, 0x1, 0x2, 0x21, 0x3,
-};
-
 static const uint8_t mv_len[16] =
 {
     10, 8, 8, 7, 8, 8, 8, 7, 8, 8, 8, 7, 7, 7, 7, 6,
@@ -286,9 +209,13 @@ static av_cold void mobiclip_init_static(void)
 
     for (int i = 0; i < 2; i++) {
         rl_vlc[i] =
-            ff_vlc_init_tables_from_lengths(&state, MOBI_RL_VLC_BITS, 104,
-                                            bits0, sizeof(*bits0),
-                                            i ? syms1 : syms0, sizeof(*syms0), sizeof(*syms0),
+            ff_vlc_init_tables_from_lengths(&state, MOBI_RL_VLC_BITS,
+                                            MOBICLIP_RL_ENTRIES,
+                                            ff_mobiclip_rl_bits,
+                                            sizeof(ff_mobiclip_rl_bits[0]),
+                                            ff_mobiclip_rl_syms[i],
+                                            sizeof(ff_mobiclip_rl_syms[i][0]),
+                                            sizeof(ff_mobiclip_rl_syms[i][0]),
                                             0, 0);
         for (int j = 0; j < 16; j++) {
             mv_vlc[i][j] =
@@ -344,7 +271,7 @@ static int setup_qtables(AVCodecContext *avctx, int64_t quantizer)
     qy = quantizer / 6;
 
     for (int i = 0; i < 16; i++)
-        s->qtab[0][i] = quant4x4_tab[qx][i] << qy;
+        s->qtab[0][i] = ff_mobiclip_quant4x4[qx][i] << qy;
 
     for (int i = 0; i < 64; i++)
         s->qtab[1][i] = quant8x8_tab[qx][i] << (qy - 2);
@@ -355,19 +282,6 @@ static int setup_qtables(AVCodecContext *avctx, int64_t quantizer)
     return 0;
 }
 
-static void inverse4(unsigned *rs)
-{
-    unsigned a = rs[0] + rs[2];
-    unsigned b = rs[0] - rs[2];
-    unsigned c = rs[1] + ((int)rs[3] >> 1);
-    unsigned d = ((int)rs[1] >> 1) - rs[3];
-
-    rs[0] = a + c;
-    rs[1] = b + d;
-    rs[2] = b - d;
-    rs[3] = a - c;
-}
-
 static void idct(int *arr, int size)
 {
     int e, f, g, h;
@@ -375,7 +289,7 @@ static void idct(int *arr, int size)
     int tmp[4];
 
     if (size == 4) {
-        inverse4(arr);
+        ff_mobiclip_inverse4(arr);
         return;
     }
 
@@ -384,7 +298,7 @@ static void idct(int *arr, int size)
     tmp[2] = arr[4];
     tmp[3] = arr[6];
 
-    inverse4(tmp);
+    ff_mobiclip_inverse4(tmp);
 
     e = (unsigned)arr[7] + arr[1] - arr[3] - (arr[3] >> 1);
     f = (unsigned)arr[7] - arr[1] + arr[5] + (arr[5] >> 1);
@@ -423,7 +337,7 @@ static int add_coefficients(AVCodecContext *avctx, AVFrame *frame,
     MobiClipContext *s = avctx->priv_data;
     GetBitContext *gb = &s->gb;
     int mat[64] = { 0 };
-    const uint8_t *ztab = size == 8 ? ff_zigzag_direct : zigzag4x4_tab;
+    const uint8_t *ztab = size == 8 ? ff_zigzag_direct : ff_mobiclip_zigzag4x4;
     const int *qtab = s->qtab[size == 8];
     uint8_t *dst = frame->data[plane] + by * frame->linesize[plane] + bx;
 
@@ -492,8 +406,8 @@ static int add_pframe_coefficients(AVCodecContext *avctx, AVFrame *frame,
 
     if (idx == 0) {
         return add_coefficients(avctx, frame, bx, by, size, plane);
-    } else if ((unsigned)idx < FF_ARRAY_ELEMS(pframe_block4x4_coefficients_tab)) {
-        int flags = pframe_block4x4_coefficients_tab[idx];
+    } else if ((unsigned)idx < FF_ARRAY_ELEMS(ff_mobiclip_p4_masks)) {
+        int flags = ff_mobiclip_p4_masks[idx];
 
         for (int y = by; y < by + 8; y += 4) {
             for (int x = bx; x < bx + 8; x += 4) {
@@ -961,7 +875,7 @@ static int process_block(AVCodecContext *avctx, AVFrame *frame,
     }
 
     tmp = get_ue_golomb_31(gb);
-    if ((unsigned)tmp > FF_ARRAY_ELEMS(block4x4_coefficients_tab))
+    if ((unsigned)tmp > FF_ARRAY_ELEMS(ff_mobiclip_i4_masks))
         return AVERROR_INVALIDDATA;
 
     if (tmp == 0) {
@@ -969,7 +883,7 @@ static int process_block(AVCodecContext *avctx, AVFrame *frame,
             pmode = get_prediction(avctx, x, y, 8);
         ret = predict_intra(avctx, frame, x, y, pmode, 1, 8, plane);
     } else {
-        int flags = block4x4_coefficients_tab[tmp - 1];
+        int flags = ff_mobiclip_i4_masks[tmp - 1];
 
         for (int by = y; by < y + 8; by += 4) {
             for (int bx = x; bx < x + 8; bx += 4) {
@@ -996,10 +910,10 @@ static int decode_macroblock(AVCodecContext *avctx, AVFrame *frame,
     int flags, pmode_uv, idx = get_ue_golomb(gb);
     int ret = 0;
 
-    if (idx < 0 || idx >= FF_ARRAY_ELEMS(block8x8_coefficients_tab))
+    if (idx < 0 || idx >= FF_ARRAY_ELEMS(ff_mobiclip_i8_masks))
         return AVERROR_INVALIDDATA;
 
-    flags = block8x8_coefficients_tab[idx];
+    flags = ff_mobiclip_i8_masks[idx];
 
     if (predict) {
         ret = process_block(avctx, frame, x, y, -1, flags & 1, 0);
@@ -1282,9 +1196,9 @@ static int mobiclip_decode(AVCodecContext *avctx, AVFrame *rframe,
                     if (ret < 0)
                         return ret;
                     idx2 = get_ue_golomb(gb);
-                    if (idx2 >= FF_ARRAY_ELEMS(pframe_block8x8_coefficients_tab))
+                    if (idx2 >= FF_ARRAY_ELEMS(ff_mobiclip_p8_masks))
                         return AVERROR_INVALIDDATA;
-                    flags = pframe_block8x8_coefficients_tab[idx2];
+                    flags = ff_mobiclip_p8_masks[idx2];
 
                     for (int sy = y; sy < y + 16; sy += 8) {
                         for (int sx = x; sx < x + 16; sx += 8) {
diff --git a/libavcodec/mobiclipdata.c b/libavcodec/mobiclipdata.c
new file mode 100644
index 0000000000..734c749e64
--- /dev/null
+++ b/libavcodec/mobiclipdata.c
@@ -0,0 +1,103 @@
+/*
+ * Shared MobiClip tables and transform primitives
+ *
+ * Copyright (c) 2015-2016 Florian Nouwt
+ * Copyright (c) 2017 Adib Surani
+ * Copyright (c) 2020 Paul B Mahol
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "mobiclipdata.h"
+
+const uint8_t ff_mobiclip_zigzag4x4[16] = {
+    0, 4, 1, 2, 5, 8, 12, 9, 6, 3, 7, 10, 13, 14, 11, 15,
+};
+
+const uint8_t ff_mobiclip_quant4x4[6][16] = {
+    { 10, 13, 13, 10, 16, 10, 13, 13, 13, 13, 16, 10, 16, 13, 13, 16 },
+    { 11, 14, 14, 11, 18, 11, 14, 14, 14, 14, 18, 11, 18, 14, 14, 18 },
+    { 13, 16, 16, 13, 20, 13, 16, 16, 16, 16, 20, 13, 20, 16, 16, 20 },
+    { 14, 18, 18, 14, 23, 14, 18, 18, 18, 18, 23, 14, 23, 18, 18, 23 },
+    { 16, 20, 20, 16, 25, 16, 20, 20, 20, 20, 25, 16, 25, 20, 20, 25 },
+    { 18, 23, 23, 18, 29, 18, 23, 23, 23, 23, 29, 18, 29, 23, 23, 29 },
+};
+
+const uint8_t ff_mobiclip_i4_masks[16] = {
+    15, 0, 2, 1, 4, 8, 12, 3, 11, 13, 14, 7, 10, 5, 9, 6,
+};
+
+const uint8_t ff_mobiclip_i8_masks[64] = {
+     0, 31, 63, 15,  8,  4,  2,  1, 11, 14, 27, 13,  3,  7, 12, 23,
+    29, 10, 30,  5, 16, 47, 55, 59, 19, 61, 62,  9, 28,  6, 21, 26,
+    51, 17, 18, 20, 24, 32, 60, 53, 25, 22, 58, 48, 49, 50, 39, 52,
+    43, 45, 57, 56, 35, 54, 46, 33, 37, 34, 36, 44, 42, 40, 41, 38,
+};
+
+const uint8_t ff_mobiclip_p4_masks[16] = {
+    0, 4, 1, 8, 2, 12, 3, 5, 10, 15, 7, 13, 14, 11, 9, 6,
+};
+
+const uint8_t ff_mobiclip_p8_masks[64] = {
+     0, 15,  4,  1,  8,  2, 12,  3,  5, 10, 13,  7, 14, 11, 31,  9,
+     6, 16, 63, 30, 23, 29, 27, 28, 19, 24, 26, 18, 17, 20, 21, 32,
+    47, 22, 25, 55, 61, 62, 59, 60, 51, 53, 33, 36, 34, 40, 35, 44,
+    48, 39, 45, 37, 58, 43, 46, 42, 49, 52, 56, 50, 41, 38, 57, 54,
+};
+
+const uint8_t ff_mobiclip_rl_bits[MOBICLIP_RL_ENTRIES] = {
+     9, 11, 11, 11, 11, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
+    10, 10, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 12,
+    12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,  7, 10, 10,  9,
+     9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,  9,
+     9,  9,  9,  9,  9,  8,  8,  8,  8,  8,  8,  8,  8,  8,  8,
+     8,  8,  8,  7,  7,  7,  7,  7,  7,  7,  7,  6,  6,  6,  6,
+     6,  6,  6,  6,  6,  6,  5,  5,  5,  4,  2,  3,  4,  4,
+};
+
+const uint16_t ff_mobiclip_rl_syms[2][MOBICLIP_RL_ENTRIES] = {
+    {
+        0x000, 0x822, 0x803, 0x00B, 0x00A, 0xB81, 0xB61, 0xB41,
+        0xB21, 0x122, 0x102, 0x0E2, 0x0C2, 0x0A2, 0x063, 0x043,
+        0x024, 0x00C, 0x025, 0x2E1, 0x301, 0xBA1, 0xBC1, 0xBE1,
+        0xC01, 0x026, 0x044, 0x083, 0x0A3, 0x0C3, 0x142, 0x321,
+        0x341, 0xC21, 0xC41, 0xC61, 0xC81, 0xCA1, 0xCC1, 0xCE1,
+        0xD01, 0x000, 0x009, 0x008, 0xB01, 0xAE1, 0xAC1, 0xAA1,
+        0xA81, 0xA61, 0xA41, 0xA21, 0x802, 0x2C1, 0x2A1, 0x281,
+        0x261, 0x241, 0x221, 0x201, 0x1E1, 0x082, 0x062, 0x007,
+        0x006, 0xA01, 0x9E1, 0x9C1, 0x9A1, 0x981, 0x961, 0x941,
+        0x921, 0x1C1, 0x1A1, 0x042, 0x023, 0x005, 0x901, 0x8E1,
+        0x8C1, 0x8A1, 0x181, 0x161, 0x141, 0x004, 0x881, 0x861,
+        0x841, 0x821, 0x121, 0x101, 0x0E1, 0x0C1, 0x022, 0x003,
+        0x0A1, 0x081, 0x061, 0x801, 0x001, 0x021, 0x041, 0x002,
+    },
+    {
+        0x000, 0x807, 0x806, 0x016, 0x015, 0x842, 0x823, 0x805,
+        0x1A1, 0x0A3, 0x102, 0x083, 0x064, 0x044, 0x027, 0x014,
+        0x013, 0x017, 0x018, 0x028, 0x122, 0x862, 0x882, 0x9E1,
+        0xA01, 0x019, 0x01A, 0x01B, 0x029, 0x0C3, 0x02A, 0x045,
+        0x0E3, 0x1C1, 0x808, 0x8A2, 0x8C2, 0xA21, 0xA41, 0xA61,
+        0xA81, 0x000, 0x012, 0x011, 0x9C1, 0x9A1, 0x981, 0x961,
+        0x941, 0x822, 0x804, 0x181, 0x161, 0x0E2, 0x0C2, 0x0A2,
+        0x063, 0x043, 0x026, 0x025, 0x010, 0x082, 0x00F, 0x00E,
+        0x00D, 0x901, 0x8E1, 0x8C1, 0x803, 0x141, 0x121, 0x101,
+        0x921, 0x062, 0x024, 0x00C, 0x00B, 0x00A, 0x881, 0x861,
+        0x0C1, 0x8A1, 0x0E1, 0x042, 0x023, 0x009, 0x802, 0x0A1,
+        0x841, 0x821, 0x081, 0x061, 0x008, 0x007, 0x022, 0x006,
+        0x041, 0x005, 0x004, 0x801, 0x001, 0x002, 0x021, 0x003,
+    },
+};
diff --git a/libavcodec/mobiclipdata.h b/libavcodec/mobiclipdata.h
new file mode 100644
index 0000000000..0c350e28e9
--- /dev/null
+++ b/libavcodec/mobiclipdata.h
@@ -0,0 +1,54 @@
+/*
+ * Shared MobiClip tables and transform primitives
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#ifndef AVCODEC_MOBICLIPDATA_H
+#define AVCODEC_MOBICLIPDATA_H
+
+#include <stdint.h>
+
+#define MOBICLIP_RL_ENTRIES 104
+
+extern const uint8_t ff_mobiclip_zigzag4x4[16];
+extern const uint8_t ff_mobiclip_quant4x4[6][16];
+
+extern const uint8_t ff_mobiclip_i4_masks[16];
+extern const uint8_t ff_mobiclip_i8_masks[64];
+extern const uint8_t ff_mobiclip_p4_masks[16];
+extern const uint8_t ff_mobiclip_p8_masks[64];
+
+extern const uint8_t  ff_mobiclip_rl_bits[MOBICLIP_RL_ENTRIES];
+extern const uint16_t ff_mobiclip_rl_syms[2][MOBICLIP_RL_ENTRIES];
+
+static inline void ff_mobiclip_inverse4(int *v)
+{
+    unsigned a = (unsigned)v[0] + v[2];
+    unsigned b = (unsigned)v[0] - v[2];
+    unsigned c = (unsigned)v[1] + (v[3] >> 1);
+    unsigned d = (v[1] >> 1) - (unsigned)v[3];
+
+    v[0] = a + c;
+    v[1] = b + d;
+    v[2] = b - d;
+    v[3] = a - c;
+}
+
+#endif /* AVCODEC_MOBICLIPDATA_H */
-- 
2.52.0


>From 33c8b5aa2c9e980afc5ed29628477c1555b8a7ea Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Thu, 30 Jul 2026 23:59:34 +0200
Subject: [PATCH 02/10] avcodec/mobiclip: expose MOC5 and Moflex bitstream
 profiles

MobiClip keyframes distinguish the original MOC5 syntax from the later Moflex syntax.

Add public profile identifiers and names, associate them with the MobiClip codec descriptor, and have the existing decoder report the detected profile. This gives applications and encoders a consistent way to identify the two bitstream variants.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 doc/APIchanges          | 3 +++
 libavcodec/codec_desc.c | 2 +-
 libavcodec/defs.h       | 3 +++
 libavcodec/mobiclip.c   | 4 ++++
 libavcodec/profiles.c   | 6 ++++++
 libavcodec/profiles.h   | 1 +
 libavcodec/version.h    | 2 +-
 7 files changed, 19 insertions(+), 2 deletions(-)

diff --git a/doc/APIchanges b/doc/APIchanges
index 7907af9290..f433e9d8c8 100644
--- a/doc/APIchanges
+++ b/doc/APIchanges
@@ -2,6 +2,9 @@ The last version increases of all libraries were on 2026-06-23.
 
 API changes, most recent first:
 
+2026-07-30 - xxxxxxxxxx - lavc 63.8.100 - defs.h
+  Add AV_PROFILE_MOBICLIP_MOC5 and AV_PROFILE_MOBICLIP_MOFLEX.
+
 2026-07-04 - xxxxxxxxxx - lavc 63.7.100 - codec_id.h
   Add AV_CODEC_ID_PCM_DVDA.
 
diff --git a/libavcodec/codec_desc.c b/libavcodec/codec_desc.c
index f5f2b17ec0..00be613542 100644
--- a/libavcodec/codec_desc.c
+++ b/libavcodec/codec_desc.c
@@ -1794,6 +1794,7 @@ static const AVCodecDescriptor codec_descriptors[] = {
         .name      = "mobiclip",
         .long_name = NULL_IF_CONFIG_SMALL("MobiClip Video"),
         .props     = AV_CODEC_PROP_LOSSY,
+        .profiles  = NULL_IF_CONFIG_SMALL(ff_mobiclip_profiles),
     },
     {
         .id        = AV_CODEC_ID_PHOTOCD,
@@ -2689,7 +2690,6 @@ static const AVCodecDescriptor codec_descriptors[] = {
         .long_name = NULL_IF_CONFIG_SMALL("ADPCM IMA Acorn Escape"),
         .props     = AV_CODEC_PROP_INTRA_ONLY | AV_CODEC_PROP_LOSSY,
     },
-
     /* AMR */
     {
         .id        = AV_CODEC_ID_AMR_NB,
diff --git a/libavcodec/defs.h b/libavcodec/defs.h
index b13e983b13..bb1b5d6d05 100644
--- a/libavcodec/defs.h
+++ b/libavcodec/defs.h
@@ -156,6 +156,9 @@
 #define AV_PROFILE_VP9_2                            2
 #define AV_PROFILE_VP9_3                            3
 
+#define AV_PROFILE_MOBICLIP_MOC5                    0
+#define AV_PROFILE_MOBICLIP_MOFLEX                  1
+
 #define AV_PROFILE_HEVC_MAIN                        1
 #define AV_PROFILE_HEVC_MAIN_10                     2
 #define AV_PROFILE_HEVC_MAIN_STILL_PICTURE          3
diff --git a/libavcodec/mobiclip.c b/libavcodec/mobiclip.c
index a5f611b248..979b43c854 100644
--- a/libavcodec/mobiclip.c
+++ b/libavcodec/mobiclip.c
@@ -36,6 +36,7 @@
 #include "golomb.h"
 #include "mathops.h"
 #include "mobiclipdata.h"
+#include "profiles.h"
 
 #define MOBI_RL_VLC_BITS 12
 #define MOBI_MV_VLC_BITS 6
@@ -1149,6 +1150,8 @@ static int mobiclip_decode(AVCodecContext *avctx, AVFrame *rframe,
         frame->pict_type = AV_PICTURE_TYPE_I;
         frame->flags |= AV_FRAME_FLAG_KEY;
         s->moflex = get_bits1(gb);
+        avctx->profile = s->moflex ? AV_PROFILE_MOBICLIP_MOFLEX :
+                                     AV_PROFILE_MOBICLIP_MOC5;
         s->dct_tab_idx = get_bits1(gb);
 
         ret = setup_qtables(avctx, get_bits(gb, 6));
@@ -1265,5 +1268,6 @@ const FFCodec ff_mobiclip_decoder = {
     .flush          = mobiclip_flush,
     .close          = mobiclip_close,
     .p.capabilities = AV_CODEC_CAP_DR1,
+    .p.profiles     = NULL_IF_CONFIG_SMALL(ff_mobiclip_profiles),
     .caps_internal  = FF_CODEC_CAP_INIT_CLEANUP,
 };
diff --git a/libavcodec/profiles.c b/libavcodec/profiles.c
index 1b67870c43..ff7c30adce 100644
--- a/libavcodec/profiles.c
+++ b/libavcodec/profiles.c
@@ -160,6 +160,12 @@ const AVProfile ff_vp9_profiles[] = {
     { AV_PROFILE_UNKNOWN },
 };
 
+const AVProfile ff_mobiclip_profiles[] = {
+    { AV_PROFILE_MOBICLIP_MOC5,   "MOC5"   },
+    { AV_PROFILE_MOBICLIP_MOFLEX, "Moflex" },
+    { AV_PROFILE_UNKNOWN },
+};
+
 const AVProfile ff_av1_profiles[] = {
     { AV_PROFILE_AV1_MAIN,         "Main" },
     { AV_PROFILE_AV1_HIGH,         "High" },
diff --git a/libavcodec/profiles.h b/libavcodec/profiles.h
index 6f4011ff0c..7ca1a655d3 100644
--- a/libavcodec/profiles.h
+++ b/libavcodec/profiles.h
@@ -71,6 +71,7 @@ extern const AVProfile ff_mpeg2_video_profiles[];
 extern const AVProfile ff_mpeg4_video_profiles[];
 extern const AVProfile ff_vc1_profiles[];
 extern const AVProfile ff_vp9_profiles[];
+extern const AVProfile ff_mobiclip_profiles[];
 extern const AVProfile ff_av1_profiles[];
 extern const AVProfile ff_sbc_profiles[];
 extern const AVProfile ff_prores_profiles[];
diff --git a/libavcodec/version.h b/libavcodec/version.h
index 7acb261bb3..37c4c39451 100644
--- a/libavcodec/version.h
+++ b/libavcodec/version.h
@@ -29,7 +29,7 @@
 
 #include "version_major.h"
 
-#define LIBAVCODEC_VERSION_MINOR   7
+#define LIBAVCODEC_VERSION_MINOR   8
 #define LIBAVCODEC_VERSION_MICRO 100
 
 #define LIBAVCODEC_VERSION_INT  AV_VERSION_INT(LIBAVCODEC_VERSION_MAJOR, \
-- 
2.52.0


>From b4d6cbd568f077348cd5c2c7de530e58316772ae Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Fri, 31 Jul 2026 00:01:12 +0200
Subject: [PATCH 03/10] avcodec: add Wii MobiClip IMA ADPCM codec

Add encoder and decoder support for the block IMA ADPCM variant carried by Wii MOC5 files.

Each per-channel block contains the predictor and step state followed by 256 samples of nibble-coded audio. Support mono and stereo layouts, validate block boundaries, and pad the final encoder frame when necessary.

Register a separate codec identifier so the format is not confused with the existing Moflex ADPCM variant.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 Changelog                   |   1 +
 doc/APIchanges              |   3 +
 doc/general_contents.texi   |   3 +-
 libavcodec/Makefile         |   2 +
 libavcodec/allcodecs.c      |   2 +
 libavcodec/codec_desc.c     |   8 ++
 libavcodec/codec_id.h       |   1 +
 libavcodec/mobiclip_adpcm.c | 220 ++++++++++++++++++++++++++++++++++++
 libavcodec/version.h        |   2 +-
 9 files changed, 240 insertions(+), 2 deletions(-)
 create mode 100644 libavcodec/mobiclip_adpcm.c

diff --git a/Changelog b/Changelog
index ce57599aa2..d377b0b9c6 100644
--- a/Changelog
+++ b/Changelog
@@ -9,6 +9,7 @@ version <next>:
 - MVR demuxer
 - latticepal filter
 - DVD-Audio LPCM decoder and demuxing support
+- Wii MobiClip IMA ADPCM encoder and decoder
 
 
 version 9.0:
diff --git a/doc/APIchanges b/doc/APIchanges
index f433e9d8c8..c8ccbb70ae 100644
--- a/doc/APIchanges
+++ b/doc/APIchanges
@@ -2,6 +2,9 @@ The last version increases of all libraries were on 2026-06-23.
 
 API changes, most recent first:
 
+2026-07-30 - xxxxxxxxxx - lavc 63.9.100 - codec_id.h
+  Add AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII.
+
 2026-07-30 - xxxxxxxxxx - lavc 63.8.100 - defs.h
   Add AV_PROFILE_MOBICLIP_MOC5 and AV_PROFILE_MOBICLIP_MOFLEX.
 
diff --git a/doc/general_contents.texi b/doc/general_contents.texi
index d81392e03f..bd1f292f54 100644
--- a/doc/general_contents.texi
+++ b/doc/general_contents.texi
@@ -1273,7 +1273,8 @@ following image formats are supported:
 @item ADPCM IMA Electronic Arts SEAD  @tab     @tab  X
 @item ADPCM IMA Funcom       @tab     @tab  X
 @item ADPCM IMA High Voltage Software ALP      @tab  X  @tab  X
-@item ADPCM IMA Mobiclip MOFLEX  @tab     @tab  X
+@item ADPCM IMA MobiClip MOFLEX  @tab     @tab  X
+@item ADPCM IMA MobiClip Wii @tab  X  @tab  X
 @item ADPCM IMA QuickTime    @tab  X  @tab  X
 @item ADPCM IMA Simon & Schuster Interactive   @tab  X  @tab  X
 @item ADPCM IMA Ubisoft APM  @tab  X  @tab  X
diff --git a/libavcodec/Makefile b/libavcodec/Makefile
index 4cf9d57b89..d3840626bd 100644
--- a/libavcodec/Makefile
+++ b/libavcodec/Makefile
@@ -1002,6 +1002,8 @@ OBJS-$(CONFIG_ADPCM_IMA_HVQM4_DECODER)    += adpcm.o adpcm_data.o
 OBJS-$(CONFIG_ADPCM_IMA_ISS_DECODER)      += adpcm.o adpcm_data.o
 OBJS-$(CONFIG_ADPCM_IMA_MAGIX_DECODER)    += adpcm.o adpcm_data.o
 OBJS-$(CONFIG_ADPCM_IMA_MOFLEX_DECODER)   += adpcm.o adpcm_data.o
+OBJS-$(CONFIG_ADPCM_IMA_MOBICLIP_WII_DECODER) += mobiclip_adpcm.o adpcm_data.o
+OBJS-$(CONFIG_ADPCM_IMA_MOBICLIP_WII_ENCODER) += mobiclip_adpcm.o adpcm_data.o
 OBJS-$(CONFIG_ADPCM_IMA_MTF_DECODER)      += adpcm.o adpcm_data.o
 OBJS-$(CONFIG_ADPCM_IMA_PDA_DECODER)      += adpcm.o adpcm_data.o
 OBJS-$(CONFIG_ADPCM_IMA_OKI_DECODER)      += adpcm.o adpcm_data.o
diff --git a/libavcodec/allcodecs.c b/libavcodec/allcodecs.c
index 2d7496a20d..7a568909a0 100644
--- a/libavcodec/allcodecs.c
+++ b/libavcodec/allcodecs.c
@@ -678,6 +678,8 @@ extern const FFCodec ff_adpcm_ima_hvqm4_decoder;
 extern const FFCodec ff_adpcm_ima_iss_decoder;
 extern const FFCodec ff_adpcm_ima_magix_decoder;
 extern const FFCodec ff_adpcm_ima_moflex_decoder;
+extern const FFCodec ff_adpcm_ima_mobiclip_wii_encoder;
+extern const FFCodec ff_adpcm_ima_mobiclip_wii_decoder;
 extern const FFCodec ff_adpcm_ima_mtf_decoder;
 extern const FFCodec ff_adpcm_ima_oki_decoder;
 extern const FFCodec ff_adpcm_ima_pda_decoder;
diff --git a/libavcodec/codec_desc.c b/libavcodec/codec_desc.c
index 00be613542..56b9d0c28d 100644
--- a/libavcodec/codec_desc.c
+++ b/libavcodec/codec_desc.c
@@ -2690,6 +2690,14 @@ static const AVCodecDescriptor codec_descriptors[] = {
         .long_name = NULL_IF_CONFIG_SMALL("ADPCM IMA Acorn Escape"),
         .props     = AV_CODEC_PROP_INTRA_ONLY | AV_CODEC_PROP_LOSSY,
     },
+    {
+        .id        = AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII,
+        .type      = AVMEDIA_TYPE_AUDIO,
+        .name      = "adpcm_ima_mobiclip_wii",
+        .long_name = NULL_IF_CONFIG_SMALL("ADPCM IMA MobiClip Wii"),
+        .props     = AV_CODEC_PROP_INTRA_ONLY | AV_CODEC_PROP_LOSSY,
+    },
+
     /* AMR */
     {
         .id        = AV_CODEC_ID_AMR_NB,
diff --git a/libavcodec/codec_id.h b/libavcodec/codec_id.h
index 7cafac610e..706052cf3b 100644
--- a/libavcodec/codec_id.h
+++ b/libavcodec/codec_id.h
@@ -429,6 +429,7 @@ enum AVCodecID {
     AV_CODEC_ID_ADPCM_PSXC,
     AV_CODEC_ID_ADPCM_CIRCUS,
     AV_CODEC_ID_ADPCM_IMA_ESCAPE,
+    AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII,
 
     /* AMR */
     AV_CODEC_ID_AMR_NB = 0x12000,
diff --git a/libavcodec/mobiclip_adpcm.c b/libavcodec/mobiclip_adpcm.c
new file mode 100644
index 0000000000..00ca576bad
--- /dev/null
+++ b/libavcodec/mobiclip_adpcm.c
@@ -0,0 +1,220 @@
+/*
+ * Wii MobiClip IMA ADPCM encoder and decoder
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "config_components.h"
+
+#include "libavutil/channel_layout.h"
+#include "libavutil/common.h"
+#include "libavutil/intreadwrite.h"
+#include "libavutil/mem.h"
+
+#include "adpcm_data.h"
+#include "avcodec.h"
+#include "codec_internal.h"
+#include "decode.h"
+#include "encode.h"
+
+#define MOBICLIP_ADPCM_SAMPLES 256
+#define MOBICLIP_ADPCM_BYTES   132
+
+typedef struct MobiClipADPCMChannel {
+    int predictor;
+    int step_index;
+} MobiClipADPCMChannel;
+
+typedef struct MobiClipADPCMContext {
+    MobiClipADPCMChannel channel[2];
+} MobiClipADPCMContext;
+
+static int16_t expand_nibble(MobiClipADPCMChannel *c, int nibble)
+{
+    int step = ff_adpcm_step_table[c->step_index];
+    int diff = ((2 * (nibble & 7) + 1) * step) >> 3;
+
+    c->predictor += nibble & 8 ? -diff : diff;
+    c->predictor  = av_clip_int16(c->predictor);
+    c->step_index = av_clip(c->step_index + ff_adpcm_index_table[nibble], 0, 88);
+    return c->predictor;
+}
+
+static int choose_nibble(MobiClipADPCMChannel *c, int sample)
+{
+    int best = 0;
+    unsigned best_error = UINT_MAX;
+
+    for (int nibble = 0; nibble < 16; nibble++) {
+        MobiClipADPCMChannel trial = *c;
+        int reconstructed = expand_nibble(&trial, nibble);
+        unsigned error = FFABS(sample - reconstructed);
+
+        if (error < best_error) {
+            best_error = error;
+            best = nibble;
+        }
+    }
+
+    expand_nibble(c, best);
+    return best;
+}
+
+static av_cold int mobiclip_adpcm_decode_init(AVCodecContext *avctx)
+{
+    if (avctx->ch_layout.nb_channels < 1 || avctx->ch_layout.nb_channels > 2)
+        return AVERROR(EINVAL);
+
+    avctx->sample_fmt = AV_SAMPLE_FMT_S16P;
+    avctx->bits_per_coded_sample = 4;
+    avctx->block_align = MOBICLIP_ADPCM_BYTES * avctx->ch_layout.nb_channels;
+    return 0;
+}
+
+static int mobiclip_adpcm_decode(AVCodecContext *avctx, AVFrame *frame,
+                                 int *got_frame, AVPacket *pkt)
+{
+    int channels = avctx->ch_layout.nb_channels;
+    int block_size = MOBICLIP_ADPCM_BYTES * channels;
+    int blocks, ret;
+    const uint8_t *src = pkt->data;
+
+    if (pkt->size <= 0 || pkt->size % block_size)
+        return AVERROR_INVALIDDATA;
+
+    blocks = pkt->size / block_size;
+    if (blocks > INT_MAX / MOBICLIP_ADPCM_SAMPLES)
+        return AVERROR_INVALIDDATA;
+
+    frame->nb_samples = blocks * MOBICLIP_ADPCM_SAMPLES;
+    if ((ret = ff_get_buffer(avctx, frame, 0)) < 0)
+        return ret;
+
+    for (int block = 0; block < blocks; block++) {
+        for (int ch = 0; ch < channels; ch++) {
+            MobiClipADPCMChannel state;
+            int16_t *dst = (int16_t *)frame->extended_data[ch] +
+                           block * MOBICLIP_ADPCM_SAMPLES;
+
+            state.step_index = (int16_t)AV_RL16(src);
+            state.predictor  = (int16_t)AV_RL16(src + 2);
+            src += 4;
+            if ((unsigned)state.step_index > 88)
+                return AVERROR_INVALIDDATA;
+
+            for (int i = 0; i < MOBICLIP_ADPCM_SAMPLES; i += 2) {
+                int packed = *src++;
+                dst[i]     = expand_nibble(&state, packed & 15);
+                dst[i + 1] = expand_nibble(&state, packed >> 4);
+            }
+        }
+    }
+
+    *got_frame = 1;
+    return pkt->size;
+}
+
+static av_cold int mobiclip_adpcm_encode_init(AVCodecContext *avctx)
+{
+    if (avctx->ch_layout.nb_channels < 1 || avctx->ch_layout.nb_channels > 2)
+        return AVERROR(EINVAL);
+
+    avctx->frame_size = MOBICLIP_ADPCM_SAMPLES;
+    avctx->block_align = MOBICLIP_ADPCM_BYTES * avctx->ch_layout.nb_channels;
+    avctx->bits_per_coded_sample = 4;
+    avctx->bit_rate = (int64_t)avctx->block_align * 8 * avctx->sample_rate /
+                      MOBICLIP_ADPCM_SAMPLES;
+    return 0;
+}
+
+static int mobiclip_adpcm_encode(AVCodecContext *avctx, AVPacket *pkt,
+                                 const AVFrame *frame, int *got_packet)
+{
+    MobiClipADPCMContext *s = avctx->priv_data;
+    int channels = avctx->ch_layout.nb_channels;
+    uint8_t *dst;
+    int ret;
+
+    if (frame->nb_samples > MOBICLIP_ADPCM_SAMPLES)
+        return AVERROR(EINVAL);
+    if ((ret = ff_get_encode_buffer(avctx, pkt,
+                                    MOBICLIP_ADPCM_BYTES * channels, 0)) < 0)
+        return ret;
+
+    dst = pkt->data;
+    for (int ch = 0; ch < channels; ch++) {
+        const int16_t *src = (const int16_t *)frame->extended_data[ch];
+        MobiClipADPCMChannel *state = &s->channel[ch];
+
+        AV_WL16(dst, state->step_index);
+        AV_WL16(dst + 2, state->predictor);
+        dst += 4;
+
+        for (int i = 0; i < MOBICLIP_ADPCM_SAMPLES; i += 2) {
+            int sample0 = i < frame->nb_samples ? src[i] : 0;
+            int sample1 = i + 1 < frame->nb_samples ? src[i + 1] : 0;
+            int lo = choose_nibble(state, sample0);
+            int hi = choose_nibble(state, sample1);
+            *dst++ = lo | hi << 4;
+        }
+    }
+
+    *got_packet = 1;
+    return 0;
+}
+
+static const enum AVSampleFormat sample_fmts[] = {
+    AV_SAMPLE_FMT_S16P, AV_SAMPLE_FMT_NONE
+};
+
+static const AVChannelLayout channel_layouts[] = {
+    AV_CHANNEL_LAYOUT_MONO,
+    AV_CHANNEL_LAYOUT_STEREO,
+    { 0 }
+};
+
+#if CONFIG_ADPCM_IMA_MOBICLIP_WII_DECODER
+const FFCodec ff_adpcm_ima_mobiclip_wii_decoder = {
+    .p.name         = "adpcm_ima_mobiclip_wii",
+    CODEC_LONG_NAME("ADPCM IMA MobiClip Wii"),
+    .p.type         = AVMEDIA_TYPE_AUDIO,
+    .p.id           = AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII,
+    .p.capabilities = AV_CODEC_CAP_DR1,
+    .priv_data_size = sizeof(MobiClipADPCMContext),
+    .init           = mobiclip_adpcm_decode_init,
+    FF_CODEC_DECODE_CB(mobiclip_adpcm_decode),
+    CODEC_CH_LAYOUTS_ARRAY(channel_layouts),
+};
+#endif
+
+#if CONFIG_ADPCM_IMA_MOBICLIP_WII_ENCODER
+const FFCodec ff_adpcm_ima_mobiclip_wii_encoder = {
+    .p.name         = "adpcm_ima_mobiclip_wii",
+    CODEC_LONG_NAME("ADPCM IMA MobiClip Wii"),
+    .p.type         = AVMEDIA_TYPE_AUDIO,
+    .p.id           = AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII,
+    .p.capabilities = AV_CODEC_CAP_DR1 | AV_CODEC_CAP_SMALL_LAST_FRAME |
+                      AV_CODEC_CAP_ENCODER_REORDERED_OPAQUE,
+    .priv_data_size = sizeof(MobiClipADPCMContext),
+    .init           = mobiclip_adpcm_encode_init,
+    FF_CODEC_ENCODE_CB(mobiclip_adpcm_encode),
+    CODEC_SAMPLEFMTS_ARRAY(sample_fmts),
+    CODEC_CH_LAYOUTS_ARRAY(channel_layouts),
+};
+#endif
diff --git a/libavcodec/version.h b/libavcodec/version.h
index 37c4c39451..230d5fa13e 100644
--- a/libavcodec/version.h
+++ b/libavcodec/version.h
@@ -29,7 +29,7 @@
 
 #include "version_major.h"
 
-#define LIBAVCODEC_VERSION_MINOR   8
+#define LIBAVCODEC_VERSION_MINOR   9
 #define LIBAVCODEC_VERSION_MICRO 100
 
 #define LIBAVCODEC_VERSION_INT  AV_VERSION_INT(LIBAVCODEC_VERSION_MAJOR, \
-- 
2.52.0


>From 2fabf180f11fb131335980e1807fe23b36659bf4 Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Fri, 31 Jul 2026 00:02:13 +0200
Subject: [PATCH 04/10] avformat/mobiclip: add MOC5 demuxer

Add demuxing support for the MOC5 container used by Wii software.

Parse the file metadata, video timing and dimensions, audio descriptions, keyframe index, and interleaved media packets. Expose MobiClip video together with the supported PCM, FastAudio, and Wii MobiClip ADPCM variants, and support both seekable and non-seekable input.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 Changelog                 |   1 +
 MAINTAINERS               |   1 +
 doc/demuxers.texi         |   6 +
 doc/general_contents.texi |   1 +
 libavformat/Makefile      |   1 +
 libavformat/allformats.c  |   1 +
 libavformat/mobiclip_mo.c | 498 ++++++++++++++++++++++++++++++++++++++
 libavformat/mobiclip_mo.h |  47 ++++
 libavformat/version.h     |   4 +-
 9 files changed, 558 insertions(+), 2 deletions(-)
 create mode 100644 libavformat/mobiclip_mo.c
 create mode 100644 libavformat/mobiclip_mo.h

diff --git a/Changelog b/Changelog
index d377b0b9c6..48bbed9d28 100644
--- a/Changelog
+++ b/Changelog
@@ -10,6 +10,7 @@ version <next>:
 - latticepal filter
 - DVD-Audio LPCM decoder and demuxing support
 - Wii MobiClip IMA ADPCM encoder and decoder
+- Wii MOC5 demuxer
 
 
 version 9.0:
diff --git a/MAINTAINERS b/MAINTAINERS
index 7ffa6982fc..2617d2aebc 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -438,6 +438,7 @@ Muxers/Demuxers:
   metadata*                             Aurelien Jacobs
   microdvd*                             Aurelien Jacobs
   mm.c                                  Peter Ross
+  mobiclip_mo*                          Martijn Brouwer
   mov.c                                 Baptiste Coudurier
   movenc.c                              Baptiste Coudurier, Matthieu Bouron
   movenccenc.c                          Eran Kornblau
diff --git a/doc/demuxers.texi b/doc/demuxers.texi
index a1dd879b59..fc003ba977 100644
--- a/doc/demuxers.texi
+++ b/doc/demuxers.texi
@@ -864,6 +864,12 @@ ffmpeg -i video_and_audio.mxf -eia608_extract 0 -i CC.mcc -c copy -map 0 -map 1
 This retains all VANC data and inserts it into the output MXF file as a @code{SMPTE_436M_ANC} data stream.
 @end itemize
 
+@section mobiclip_mo
+
+Demuxer for the MobiClip MOC5 container used by Wii software. It reads the
+video timing and dimensions, keyframe index, MobiClip video packets, and the
+supported PCM, ADPCM, and FastAudio audio descriptions.
+
 @section mov/mp4/3gp
 
 Demuxer for Quicktime File Format & ISO/IEC Base Media File Format (ISO/IEC 14496-12 or MPEG-4 Part 12, ISO/IEC 15444-12 or JPEG 2000 Part 12).
diff --git a/doc/general_contents.texi b/doc/general_contents.texi
index bd1f292f54..b58567fb03 100644
--- a/doc/general_contents.texi
+++ b/doc/general_contents.texi
@@ -621,6 +621,7 @@ library:
 @item Metal Gear Solid: The Twin Snakes @tab @tab X
 @item Megalux Frame             @tab   @tab X
     @tab Used by Megalux Ultimate Paint
+@item MobiClip MOC5             @tab   @tab X
 @item MobiClip MODS             @tab   @tab X
 @item MobiClip MOFLEX           @tab   @tab X
 @item Mobotix .mxg              @tab   @tab X
diff --git a/libavformat/Makefile b/libavformat/Makefile
index 45084227a6..5365fd34f3 100644
--- a/libavformat/Makefile
+++ b/libavformat/Makefile
@@ -388,6 +388,7 @@ OBJS-$(CONFIG_MMF_DEMUXER)               += mmf.o
 OBJS-$(CONFIG_MMF_MUXER)                 += mmf.o rawenc.o
 OBJS-$(CONFIG_MODS_DEMUXER)              += mods.o
 OBJS-$(CONFIG_MOFLEX_DEMUXER)            += moflex.o
+OBJS-$(CONFIG_MOBICLIP_MO_DEMUXER)       += mobiclip_mo.o
 OBJS-$(CONFIG_MOV_DEMUXER)               += mov.o mov_chan.o mov_esds.o \
                                             qtpalette.o replaygain.o dovi_isom.o \
                                             dvdclut.o
diff --git a/libavformat/allformats.c b/libavformat/allformats.c
index e121c7441c..bc1fe33811 100644
--- a/libavformat/allformats.c
+++ b/libavformat/allformats.c
@@ -288,6 +288,7 @@ extern const FFInputFormat  ff_mmf_demuxer;
 extern const FFOutputFormat ff_mmf_muxer;
 extern const FFInputFormat  ff_mods_demuxer;
 extern const FFInputFormat  ff_moflex_demuxer;
+extern const FFInputFormat  ff_mobiclip_mo_demuxer;
 extern const FFInputFormat  ff_mov_demuxer;
 extern const FFOutputFormat ff_mov_muxer;
 extern const FFOutputFormat ff_mp2_muxer;
diff --git a/libavformat/mobiclip_mo.c b/libavformat/mobiclip_mo.c
new file mode 100644
index 0000000000..7754ce3e5a
--- /dev/null
+++ b/libavformat/mobiclip_mo.c
@@ -0,0 +1,498 @@
+/*
+ * MobiClip MOC5 demuxer
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "libavutil/intreadwrite.h"
+#include "libavutil/mathematics.h"
+
+#include "avformat.h"
+#include "avio_internal.h"
+#include "demux.h"
+#include "internal.h"
+#include "mobiclip_mo.h"
+
+typedef struct MOC5DemuxContext {
+    int audio_pending;
+    int audio_stream;
+    int audio_size;
+    int64_t next_chunk;
+    int64_t audio_pts;
+    int64_t frame;
+    int64_t frame_count;
+    int64_t file_size;
+    uint32_t fps_fixed;
+    uint32_t max_chunk_size;
+    int audio_started;
+    int saw_trailer;
+} MOC5DemuxContext;
+
+static int read_exact(AVIOContext *pb, uint8_t *buf, int size)
+{
+    int ret = ffio_read_size(pb, buf, size);
+
+    return ret < 0 ? ret : 0;
+}
+
+static int skip_to(AVIOContext *pb, int64_t target)
+{
+    int64_t pos = avio_tell(pb);
+    int64_t ret;
+
+    if (pos < 0)
+        return AVERROR(EIO);
+    if (target < pos)
+        return AVERROR_INVALIDDATA;
+    ret = avio_skip(pb, target - pos);
+    if (ret < 0)
+        return ret;
+    return ret == target ? 0 : AVERROR_INVALIDDATA;
+}
+
+static int moc5_probe(const AVProbeData *p)
+{
+    uint32_t header_size;
+
+    if (p->buf_size < 12 || AV_RL32(p->buf) != MOC5_MAGIC)
+        return 0;
+    header_size = AV_RL32(p->buf + 4);
+    if (header_size < 20 || AV_RL16(p->buf + 8) != MOC5_TAG_TIMING)
+        return 0;
+    return AVPROBE_SCORE_MAX;
+}
+
+static int read_audio_description(AVFormatContext *s, uint16_t tag,
+                                  unsigned payload_size)
+{
+    MOC5DemuxContext *c = s->priv_data;
+    AVStream *st;
+    uint8_t description[8];
+    uint32_t sample_rate, channels;
+    enum AVCodecID codec_id;
+    int bits_per_coded_sample = 0;
+    int block_align;
+    int expected_channels = 0;
+    int ret;
+
+    if (payload_size != sizeof(description))
+        return AVERROR_INVALIDDATA;
+    if (c->audio_stream >= 0)
+        return AVERROR_INVALIDDATA;
+
+    if ((ret = read_exact(s->pb, description, sizeof(description))) < 0)
+        return ret;
+    sample_rate = AV_RL32(description);
+    channels    = AV_RL32(description + 4);
+    if (!sample_rate || sample_rate > INT_MAX || channels < 1 || channels > 2)
+        return AVERROR_INVALIDDATA;
+
+    switch (tag) {
+    case MOC5_TAG_ADPCM:
+        expected_channels = 1;
+        codec_id = AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII;
+        block_align = MOC5_ADPCM_BYTES * channels;
+        bits_per_coded_sample = 4;
+        break;
+    case MOC5_TAG_ADPCM_2CH:
+        expected_channels = 2;
+        codec_id = AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII;
+        block_align = MOC5_ADPCM_BYTES * channels;
+        bits_per_coded_sample = 4;
+        break;
+    case MOC5_TAG_PCM:
+        codec_id = AV_CODEC_ID_PCM_S16LE;
+        block_align = 2 * channels;
+        bits_per_coded_sample = 16;
+        break;
+    case MOC5_TAG_FASTAUDIO:
+        expected_channels = 1;
+        codec_id = AV_CODEC_ID_FASTAUDIO;
+        block_align = 40 * channels;
+        break;
+    case MOC5_TAG_FASTAUDIO_2CH:
+        expected_channels = 2;
+        codec_id = AV_CODEC_ID_FASTAUDIO;
+        block_align = 40 * channels;
+        break;
+    default:
+        return AVERROR_INVALIDDATA;
+    }
+    if (expected_channels && channels != expected_channels)
+        return AVERROR_INVALIDDATA;
+
+    st = avformat_new_stream(s, NULL);
+    if (!st)
+        return AVERROR(ENOMEM);
+    c->audio_stream = st->index;
+    st->codecpar->codec_type  = AVMEDIA_TYPE_AUDIO;
+    st->codecpar->codec_id    = codec_id;
+    st->codecpar->sample_rate = sample_rate;
+    st->codecpar->block_align = block_align;
+    st->codecpar->bits_per_coded_sample = bits_per_coded_sample;
+    av_channel_layout_default(&st->codecpar->ch_layout, channels);
+    avpriv_set_pts_info(st, 64, 1, sample_rate);
+    return 0;
+}
+
+static int moc5_read_header(AVFormatContext *s)
+{
+    MOC5DemuxContext *c = s->priv_data;
+    AVIOContext *pb = s->pb;
+    AVStream *video;
+    int64_t header_end;
+    uint8_t file_header[8];
+    int saw_timing = 0;
+    int saw_video = 0;
+    int saw_index = 0;
+    int saw_end = 0;
+    int ret;
+
+    uint32_t header_size;
+
+    if ((ret = read_exact(pb, file_header, sizeof(file_header))) < 0)
+        return ret;
+    if (AV_RL32(file_header) != MOC5_MAGIC)
+        return AVERROR_INVALIDDATA;
+    header_size = AV_RL32(file_header + 4);
+    if (header_size > INT_MAX - 8)
+        return AVERROR_INVALIDDATA;
+    header_end = 8LL + header_size;
+    c->file_size = pb->seekable & AVIO_SEEKABLE_NORMAL ? avio_size(pb) : -1;
+    if (header_end < 28 || (c->file_size >= 0 && header_end > c->file_size))
+        return AVERROR_INVALIDDATA;
+
+    video = avformat_new_stream(s, NULL);
+    if (!video)
+        return AVERROR(ENOMEM);
+    video->codecpar->codec_type = AVMEDIA_TYPE_VIDEO;
+    video->codecpar->codec_id   = AV_CODEC_ID_MOBICLIP;
+    c->audio_stream = -1;
+
+    while (!saw_end) {
+        uint8_t tag_header[4];
+        uint16_t tag;
+        unsigned payload_size;
+        int64_t payload_start;
+        int64_t payload_end;
+
+        if (avio_tell(pb) < 0 || avio_tell(pb) > header_end - 4)
+            return AVERROR_INVALIDDATA;
+        if ((ret = read_exact(pb, tag_header, sizeof(tag_header))) < 0)
+            return ret;
+        tag = AV_RL16(tag_header);
+        payload_size = 4U * AV_RL16(tag_header + 2);
+        payload_start = avio_tell(pb);
+        if (payload_start < 0 || payload_size > header_end - payload_start)
+            return AVERROR_INVALIDDATA;
+        payload_end = payload_start + payload_size;
+
+        if (!saw_timing && tag != MOC5_TAG_TIMING)
+            return AVERROR_INVALIDDATA;
+
+        ret = 0;
+        switch (tag) {
+        case MOC5_TAG_TIMING:
+            if (saw_timing || payload_size != 12) {
+                return AVERROR_INVALIDDATA;
+            } else {
+                uint8_t timing[12];
+
+                if ((ret = read_exact(pb, timing, sizeof(timing))) < 0)
+                    return ret;
+                c->fps_fixed     = AV_RL32(timing);
+                c->frame_count   = AV_RL32(timing + 4);
+                c->max_chunk_size = AV_RL32(timing + 8);
+            }
+            video->duration = c->frame_count;
+            if (!c->fps_fixed)
+                return AVERROR_INVALIDDATA;
+            avpriv_set_pts_info(video, 64, 256, c->fps_fixed);
+            saw_timing = 1;
+            break;
+        case MOC5_TAG_VIDEO:
+            if (saw_video || payload_size != 8) {
+                return AVERROR_INVALIDDATA;
+            } else {
+                uint8_t dimensions[8];
+                uint32_t width, height;
+
+                if ((ret = read_exact(pb, dimensions, sizeof(dimensions))) < 0)
+                    return ret;
+                width  = AV_RL32(dimensions);
+                height = AV_RL32(dimensions + 4);
+                if (!width || !height || width > INT_MAX || height > INT_MAX)
+                    return AVERROR_INVALIDDATA;
+                video->codecpar->width  = width;
+                video->codecpar->height = height;
+            }
+            saw_video = 1;
+            break;
+        case MOC5_TAG_ADPCM:
+        case MOC5_TAG_ADPCM_2CH:
+        case MOC5_TAG_PCM:
+        case MOC5_TAG_FASTAUDIO:
+        case MOC5_TAG_FASTAUDIO_2CH:
+            ret = read_audio_description(s, tag, payload_size);
+            break;
+        case MOC5_TAG_KEY_INDEX: {
+            uint32_t previous_pos = 0, previous_frame = 0;
+            int have_entry = 0;
+
+            if (!saw_video || saw_index || (payload_size & 7))
+                return AVERROR_INVALIDDATA;
+            for (unsigned i = 0; i + 8 <= payload_size; i += 8) {
+                uint8_t index_entry[8];
+                uint32_t pos, frame;
+
+                if ((ret = read_exact(pb, index_entry, sizeof(index_entry))) < 0)
+                    return ret;
+                pos   = AV_RL32(index_entry);
+                frame = AV_RL32(index_entry + 4);
+                if (frame >= c->frame_count || pos < header_end || (pos & 3) ||
+                    (c->file_size >= 0 && pos >= c->file_size) ||
+                    (have_entry &&
+                     (pos <= previous_pos || frame <= previous_frame)))
+                    return AVERROR_INVALIDDATA;
+                ret = av_add_index_entry(video, pos, frame, 0, 0,
+                                         AVINDEX_KEYFRAME);
+                if (ret < 0)
+                    return ret;
+                previous_pos   = pos;
+                previous_frame = frame;
+                have_entry     = 1;
+            }
+            saw_index = 1;
+            break;
+        }
+        case MOC5_TAG_END:
+            if (payload_size || payload_end != header_end)
+                return AVERROR_INVALIDDATA;
+            saw_end = 1;
+            break;
+        default:
+            break;
+        }
+        if (ret < 0)
+            return ret;
+        if (avio_tell(pb) > payload_end)
+            return AVERROR_INVALIDDATA;
+        if ((ret = skip_to(pb, payload_end)) < 0)
+            return ret;
+    }
+
+    if (!saw_timing || !saw_video)
+        return AVERROR_INVALIDDATA;
+    return 0;
+}
+
+static int moc5_read_audio(AVFormatContext *s, AVPacket *pkt)
+{
+    MOC5DemuxContext *c = s->priv_data;
+    AVStream *st = s->streams[c->audio_stream];
+    int64_t pos;
+    int64_t samples = 0;
+    int ret;
+
+    pos = avio_tell(s->pb);
+    if (pos < 0 || pos > c->next_chunk || c->audio_size > c->next_chunk - pos)
+        return AVERROR_INVALIDDATA;
+    ret = av_get_packet(s->pb, pkt, c->audio_size);
+    if (ret >= 0 && ret != c->audio_size) {
+        av_packet_unref(pkt);
+        return AVERROR_INVALIDDATA;
+    }
+    if (ret < 0)
+        return ret;
+    if ((ret = skip_to(s->pb, c->next_chunk)) < 0) {
+        av_packet_unref(pkt);
+        return ret;
+    }
+    c->audio_pending = 0;
+
+    if (st->codecpar->codec_id == AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII)
+        samples = c->audio_size / st->codecpar->block_align * MOC5_ADPCM_SAMPLES;
+    else if (st->codecpar->codec_id == AV_CODEC_ID_FASTAUDIO)
+        samples = c->audio_size / st->codecpar->block_align * 256;
+    else if (st->codecpar->codec_id == AV_CODEC_ID_PCM_S16LE)
+        samples = c->audio_size / st->codecpar->block_align;
+    if (samples > INT64_MAX - c->audio_pts) {
+        av_packet_unref(pkt);
+        return AVERROR_INVALIDDATA;
+    }
+
+    pkt->stream_index = c->audio_stream;
+    pkt->pts = pkt->dts = c->audio_pts;
+    pkt->duration = samples;
+    c->audio_pts += samples;
+    return pkt->size;
+}
+
+static int moc5_read_packet(AVFormatContext *s, AVPacket *pkt)
+{
+    MOC5DemuxContext *c = s->priv_data;
+    AVIOContext *pb = s->pb;
+
+    for (;;) {
+        uint8_t chunk_header[8];
+        int64_t chunk_pos, raw_end;
+        uint32_t chunk_size, video_size;
+        unsigned pad, audio_size;
+        int ret;
+
+        if (c->audio_pending)
+            return moc5_read_audio(s, pkt);
+
+        if (c->frame >= c->frame_count) {
+            uint8_t trailer[4];
+
+            if (c->saw_trailer)
+                return AVERROR_EOF;
+            ret = read_exact(pb, trailer, sizeof(trailer));
+            if (ret < 0)
+                return ret;
+            if (AV_RL32(trailer))
+                return AVERROR_INVALIDDATA;
+            c->saw_trailer = 1;
+            return AVERROR_EOF;
+        }
+        chunk_pos = avio_tell(pb);
+        if (chunk_pos < 0)
+            return AVERROR(EIO);
+        if ((ret = read_exact(pb, chunk_header, sizeof(chunk_header))) < 0)
+            return ret;
+        chunk_size = AV_RL32(chunk_header);
+        video_size = AV_RL32(chunk_header + 4);
+        if (chunk_size < 8 || chunk_size > INT_MAX || !video_size ||
+            video_size > INT_MAX ||
+            video_size > chunk_size - 4)
+            return AVERROR_INVALIDDATA;
+        if (c->max_chunk_size && chunk_size > c->max_chunk_size)
+            return AVERROR_INVALIDDATA;
+
+        if (chunk_size > INT64_MAX - chunk_pos - 4)
+            return AVERROR_INVALIDDATA;
+        raw_end = chunk_pos + chunk_size;
+        pad = 4 - (raw_end & 3);
+        c->next_chunk = raw_end + pad;
+        if (c->next_chunk < raw_end ||
+            (c->file_size >= 0 && c->next_chunk > c->file_size))
+            return AVERROR_INVALIDDATA;
+        if (video_size > c->next_chunk - chunk_pos - 8)
+            return AVERROR_INVALIDDATA;
+        audio_size = chunk_size >= video_size + 8 ?
+                     chunk_size - video_size - 8 : 0;
+
+        if (c->audio_stream >= 0 && audio_size) {
+            AVCodecParameters *par = s->streams[c->audio_stream]->codecpar;
+            int block = par->block_align;
+            if (block > 0 && audio_size % block &&
+                audio_size + pad <= INT_MAX && !((audio_size + pad) % block))
+                audio_size += pad;
+            if (block <= 0 || audio_size % block)
+                return AVERROR_INVALIDDATA;
+            c->audio_size = audio_size;
+            c->audio_pending = 1;
+            if (!c->audio_started) {
+                AVStream *audio = s->streams[c->audio_stream];
+                c->audio_pts = av_rescale_q(c->frame, s->streams[0]->time_base,
+                                            audio->time_base);
+                c->audio_started = 1;
+            }
+        }
+
+        ret = av_get_packet(pb, pkt, video_size);
+        if (ret < 0)
+            return ret;
+        if (ret != video_size) {
+            av_packet_unref(pkt);
+            return AVERROR_INVALIDDATA;
+        }
+        pkt->stream_index = 0;
+        pkt->pts = pkt->dts = c->frame;
+        pkt->duration = 1;
+        pkt->pos = chunk_pos;
+        if (!c->frame) {
+            pkt->flags |= AV_PKT_FLAG_KEY;
+        } else {
+            const AVIndexEntry *entry =
+                avformat_index_get_entry_from_timestamp(s->streams[0], c->frame,
+                                                        AVSEEK_FLAG_ANY);
+            if (entry && entry->timestamp == c->frame)
+                pkt->flags |= AV_PKT_FLAG_KEY;
+        }
+        c->frame++;
+
+        if (!c->audio_pending) {
+            int skip_ret = skip_to(pb, c->next_chunk);
+
+            if (skip_ret < 0) {
+                av_packet_unref(pkt);
+                return skip_ret;
+            }
+        }
+        return ret;
+    }
+}
+
+static int moc5_read_seek(AVFormatContext *s, int stream_index,
+                          int64_t timestamp, int flags)
+{
+    MOC5DemuxContext *c = s->priv_data;
+    AVStream *video = s->streams[0];
+    const AVIndexEntry *entry;
+    int index;
+
+    if (!(s->pb->seekable & AVIO_SEEKABLE_NORMAL))
+        return AVERROR(ENOSYS);
+    if (stream_index < 0 || stream_index >= s->nb_streams)
+        stream_index = 0;
+    if (stream_index) {
+        timestamp = av_rescale_q(timestamp, s->streams[stream_index]->time_base,
+                                 video->time_base);
+    }
+    index = av_index_search_timestamp(video, timestamp, flags);
+    if (index < 0)
+        return index;
+    entry = avformat_index_get_entry(video, index);
+    if (!entry)
+        return AVERROR_INVALIDDATA;
+    if (avio_seek(s->pb, entry->pos, SEEK_SET) < 0)
+        return AVERROR(EIO);
+
+    c->frame = entry->timestamp;
+    c->audio_pending = 0;
+    c->audio_started = 0;
+    c->audio_pts = 0;
+    c->saw_trailer = 0;
+    return 0;
+}
+
+const FFInputFormat ff_mobiclip_mo_demuxer = {
+    .p.name         = "mobiclip_mo",
+    .p.long_name    = NULL_IF_CONFIG_SMALL("MobiClip MOC5"),
+    .p.extensions   = "mo",
+    .p.flags        = AVFMT_GENERIC_INDEX,
+    .priv_data_size = sizeof(MOC5DemuxContext),
+    .flags_internal = FF_INFMT_FLAG_INIT_CLEANUP,
+    .read_probe     = moc5_probe,
+    .read_header    = moc5_read_header,
+    .read_packet    = moc5_read_packet,
+    .read_seek      = moc5_read_seek,
+};
diff --git a/libavformat/mobiclip_mo.h b/libavformat/mobiclip_mo.h
new file mode 100644
index 0000000000..335c14bf6e
--- /dev/null
+++ b/libavformat/mobiclip_mo.h
@@ -0,0 +1,47 @@
+/*
+ * MobiClip MOC5 container definitions
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#ifndef AVFORMAT_MOBICLIP_MO_H
+#define AVFORMAT_MOBICLIP_MO_H
+
+#include "libavutil/common.h"
+
+#define MOC5_MAGIC MKTAG('M', 'O', 'C', '5')
+#define MOC5_TAG(a, b) ((a) | (b) << 8)
+
+enum MOC5Tag {
+    MOC5_TAG_TIMING        = MOC5_TAG('T', 'L'),
+    MOC5_TAG_VIDEO         = MOC5_TAG('V', '2'),
+    MOC5_TAG_CERTIFICATE   = MOC5_TAG('p', 'c'),
+    MOC5_TAG_FASTAUDIO     = MOC5_TAG('A', '2'),
+    MOC5_TAG_FASTAUDIO_2CH = MOC5_TAG('A', '3'),
+    MOC5_TAG_PCM           = MOC5_TAG('A', 'P'),
+    MOC5_TAG_ADPCM         = MOC5_TAG('A', '8'),
+    MOC5_TAG_ADPCM_2CH     = MOC5_TAG('A', '9'),
+    MOC5_TAG_KEY_INDEX     = MOC5_TAG('K', 'I'),
+    MOC5_TAG_END           = MOC5_TAG('H', 'E'),
+};
+
+#define MOC5_ADPCM_SAMPLES 256
+#define MOC5_ADPCM_BYTES   132
+
+#endif /* AVFORMAT_MOBICLIP_MO_H */
diff --git a/libavformat/version.h b/libavformat/version.h
index 384cbd49cc..4bde82abb4 100644
--- a/libavformat/version.h
+++ b/libavformat/version.h
@@ -31,8 +31,8 @@
 
 #include "version_major.h"
 
-#define LIBAVFORMAT_VERSION_MINOR   5
-#define LIBAVFORMAT_VERSION_MICRO 101
+#define LIBAVFORMAT_VERSION_MINOR   6
+#define LIBAVFORMAT_VERSION_MICRO 100
 
 #define LIBAVFORMAT_VERSION_INT AV_VERSION_INT(LIBAVFORMAT_VERSION_MAJOR, \
                                                LIBAVFORMAT_VERSION_MINOR, \
-- 
2.52.0


>From d50406fb4d2a2493dff873effc69295ef2d10b4f Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Fri, 31 Jul 2026 00:02:51 +0200
Subject: [PATCH 05/10] avformat/mobiclip: add MOC5 muxer

Add muxing support for the Wii MOC5 container.

Accept one MobiClip video stream and an optional Wii MobiClip ADPCM stream. Write the media packet layout, construct the keyframe index, and finalize the header on seekable output.

Add an option for copying a caller-supplied 160-byte MOC5 certificate into the file. Warn when no certificate is supplied because some players require one.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 Changelog                    |   2 +-
 doc/general_contents.texi    |   2 +-
 doc/muxers.texi              |  18 ++
 libavformat/Makefile         |   1 +
 libavformat/allformats.c     |   1 +
 libavformat/mobiclip_moenc.c | 535 +++++++++++++++++++++++++++++++++++
 libavformat/version.h        |   2 +-
 7 files changed, 558 insertions(+), 3 deletions(-)
 create mode 100644 libavformat/mobiclip_moenc.c

diff --git a/Changelog b/Changelog
index 48bbed9d28..5c53de891d 100644
--- a/Changelog
+++ b/Changelog
@@ -10,7 +10,7 @@ version <next>:
 - latticepal filter
 - DVD-Audio LPCM decoder and demuxing support
 - Wii MobiClip IMA ADPCM encoder and decoder
-- Wii MOC5 demuxer
+- Wii MOC5 muxer and demuxer
 
 
 version 9.0:
diff --git a/doc/general_contents.texi b/doc/general_contents.texi
index b58567fb03..9d606a2d13 100644
--- a/doc/general_contents.texi
+++ b/doc/general_contents.texi
@@ -621,7 +621,7 @@ library:
 @item Metal Gear Solid: The Twin Snakes @tab @tab X
 @item Megalux Frame             @tab   @tab X
     @tab Used by Megalux Ultimate Paint
-@item MobiClip MOC5             @tab   @tab X
+@item MobiClip MOC5             @tab X @tab X
 @item MobiClip MODS             @tab   @tab X
 @item MobiClip MOFLEX           @tab   @tab X
 @item Mobotix .mxg              @tab   @tab X
diff --git a/doc/muxers.texi b/doc/muxers.texi
index 99584e2b8d..271e0662d1 100644
--- a/doc/muxers.texi
+++ b/doc/muxers.texi
@@ -3068,6 +3068,24 @@ assistants.
 
 This muxer accepts a single @samp{adpcm_yamaha} audio stream.
 
+@section mobiclip_mo
+
+MobiClip MOC5 container muxer. The muxer accepts one @code{mobiclip} video
+stream and, optionally, Wii MobiClip ADPCM. Output must be seekable because
+the header and keyframe index are completed after all packets have been
+written.
+
+@subsection Options
+
+@table @option
+@item moc5_certificate @var{path}
+Path to a 160-byte MOC5 @code{pc} certificate. Supplying a certificate writes
+the corresponding header entry.
+@end table
+
+When no certificate is supplied, the muxer omits the @code{pc} entry. Some
+players reject files without a valid certificate.
+
 @section mp3
 
 The MP3 muxer writes a raw MP3 stream with the following optional features:
diff --git a/libavformat/Makefile b/libavformat/Makefile
index 5365fd34f3..25003b7dcd 100644
--- a/libavformat/Makefile
+++ b/libavformat/Makefile
@@ -389,6 +389,7 @@ OBJS-$(CONFIG_MMF_MUXER)                 += mmf.o rawenc.o
 OBJS-$(CONFIG_MODS_DEMUXER)              += mods.o
 OBJS-$(CONFIG_MOFLEX_DEMUXER)            += moflex.o
 OBJS-$(CONFIG_MOBICLIP_MO_DEMUXER)       += mobiclip_mo.o
+OBJS-$(CONFIG_MOBICLIP_MO_MUXER)         += mobiclip_moenc.o
 OBJS-$(CONFIG_MOV_DEMUXER)               += mov.o mov_chan.o mov_esds.o \
                                             qtpalette.o replaygain.o dovi_isom.o \
                                             dvdclut.o
diff --git a/libavformat/allformats.c b/libavformat/allformats.c
index bc1fe33811..dd6b974dc4 100644
--- a/libavformat/allformats.c
+++ b/libavformat/allformats.c
@@ -289,6 +289,7 @@ extern const FFOutputFormat ff_mmf_muxer;
 extern const FFInputFormat  ff_mods_demuxer;
 extern const FFInputFormat  ff_moflex_demuxer;
 extern const FFInputFormat  ff_mobiclip_mo_demuxer;
+extern const FFOutputFormat ff_mobiclip_mo_muxer;
 extern const FFInputFormat  ff_mov_demuxer;
 extern const FFOutputFormat ff_mov_muxer;
 extern const FFOutputFormat ff_mp2_muxer;
diff --git a/libavformat/mobiclip_moenc.c b/libavformat/mobiclip_moenc.c
new file mode 100644
index 0000000000..8ba5184710
--- /dev/null
+++ b/libavformat/mobiclip_moenc.c
@@ -0,0 +1,535 @@
+/*
+ * MobiClip MOC5 muxer
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "libavutil/intreadwrite.h"
+#include "libavutil/mathematics.h"
+#include "libavutil/mem.h"
+#include "libavutil/opt.h"
+
+#include "avformat.h"
+#include "avio.h"
+#include "avio_internal.h"
+#include "mux.h"
+#include "mobiclip_mo.h"
+
+typedef struct MOC5MuxContext {
+    const AVClass *class;
+    char *certificate_path;
+
+    uint8_t *header;
+    int header_size;
+    int frame_count_offset;
+    int max_chunk_offset;
+    int index_offset;
+
+    uint8_t certificate[160];
+    int has_certificate;
+
+    uint32_t *key_offset;
+    uint32_t *key_frame;
+    int key_count;
+    int key_capacity;
+
+    uint8_t *video;
+    int video_size;
+    int video_key;
+    uint8_t *audio;
+    int audio_size;
+
+    int audio_stream;
+    int audio_block;
+    int audio_sample_rate;
+    int audio_samples_per_block;
+    int64_t audio_blocks_emitted;
+    uint32_t fps_fixed;
+    int underflow_logged;
+    uint32_t frame_count;
+    uint32_t max_chunk_size;
+    int64_t file_end;
+} MOC5MuxContext;
+
+static int read_exact_file(AVFormatContext *s, const char *path,
+                           uint8_t *dst, int expected_size)
+{
+    AVIOContext *input = NULL;
+    uint8_t extra;
+    int ret, extra_ret;
+
+    if ((ret = avio_open2(&input, path, AVIO_FLAG_READ,
+                          &s->interrupt_callback, NULL)) < 0)
+        return ret;
+    ret = ffio_read_size(input, dst, expected_size);
+    if (ret < 0)
+        goto end;
+    extra_ret = avio_read(input, &extra, 1);
+    if (extra_ret < 0 && extra_ret != AVERROR_EOF) {
+        ret = extra_ret;
+        goto end;
+    }
+    if (extra_ret > 0) {
+        av_log(s, AV_LOG_ERROR, "%s must contain exactly %d bytes.\n",
+               path, expected_size);
+        ret = AVERROR_INVALIDDATA;
+    } else {
+        ret = 0;
+    }
+end:
+    avio_closep(&input);
+    return ret;
+}
+
+static int load_certificate(AVFormatContext *s)
+{
+    MOC5MuxContext *c = s->priv_data;
+    int ret;
+
+    if (!c->certificate_path)
+        return 0;
+
+    ret = read_exact_file(s, c->certificate_path, c->certificate,
+                          sizeof(c->certificate));
+    if (ret < 0)
+        return ret;
+    c->has_certificate = 1;
+    return 0;
+}
+
+static void write_tag(AVIOContext *pb, uint16_t tag, unsigned payload_size)
+{
+    avio_wl16(pb, tag);
+    avio_wl16(pb, payload_size / 4);
+}
+
+static int make_header(AVFormatContext *s, AVStream *video, AVStream *audio)
+{
+    MOC5MuxContext *c = s->priv_data;
+    AVIOContext *pb;
+    AVRational rate = video->avg_frame_rate;
+    int64_t fps_fixed64;
+    uint32_t fps_fixed;
+    int ret;
+
+    if (rate.num <= 0 || rate.den <= 0)
+        rate = video->r_frame_rate;
+    if (rate.num <= 0 || rate.den <= 0)
+        rate = av_inv_q(video->time_base);
+    if (rate.num <= 0 || rate.den <= 0)
+        return AVERROR(EINVAL);
+    fps_fixed64 = av_rescale_rnd(rate.num, 256, rate.den, AV_ROUND_NEAR_INF);
+    if (fps_fixed64 <= 0 || fps_fixed64 > UINT32_MAX)
+        return AVERROR(ERANGE);
+    fps_fixed = fps_fixed64;
+    c->fps_fixed = fps_fixed;
+
+    if ((ret = avio_open_dyn_buf(&pb)) < 0)
+        return ret;
+    avio_wl32(pb, MOC5_MAGIC);
+    avio_wl32(pb, 0);
+
+    write_tag(pb, MOC5_TAG_TIMING, 12);
+    avio_wl32(pb, fps_fixed);
+    c->frame_count_offset = avio_tell(pb);
+    avio_wl32(pb, 0);
+    c->max_chunk_offset = avio_tell(pb);
+    avio_wl32(pb, 0);
+
+    write_tag(pb, MOC5_TAG_VIDEO, 8);
+    avio_wl32(pb, video->codecpar->width);
+    avio_wl32(pb, video->codecpar->height);
+
+    if (c->has_certificate) {
+        write_tag(pb, MOC5_TAG_CERTIFICATE, sizeof(c->certificate));
+        avio_write(pb, c->certificate, sizeof(c->certificate));
+    }
+
+    if (audio) {
+        int channels = audio->codecpar->ch_layout.nb_channels;
+        uint16_t tag = channels == 2 ? MOC5_TAG_ADPCM_2CH : MOC5_TAG_ADPCM;
+
+        if (audio->codecpar->codec_id != AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII) {
+            avio_close_dyn_buf(pb, &c->header);
+            av_freep(&c->header);
+            return AVERROR(EINVAL);
+        }
+        write_tag(pb, tag, 8);
+        avio_wl32(pb, audio->codecpar->sample_rate);
+        avio_wl32(pb, channels);
+    }
+
+    c->index_offset = avio_tell(pb);
+    write_tag(pb, MOC5_TAG_KEY_INDEX, 0);
+
+    write_tag(pb, MOC5_TAG_END, 0);
+
+    c->header_size = avio_close_dyn_buf(pb, &c->header);
+    if (c->header_size < 0)
+        return c->header_size;
+    AV_WL32(c->header + 4, c->header_size - 8);
+    return 0;
+}
+
+static int moc5_write_header(AVFormatContext *s)
+{
+    MOC5MuxContext *c = s->priv_data;
+    AVStream *video = NULL, *audio = NULL;
+    int ret;
+
+    if (!(s->pb->seekable & AVIO_SEEKABLE_NORMAL)) {
+        av_log(s, AV_LOG_ERROR, "MOC5 output must be seekable.\n");
+        return AVERROR(ENOSYS);
+    }
+    for (unsigned i = 0; i < s->nb_streams; i++) {
+        AVStream *st = s->streams[i];
+        if (st->codecpar->codec_type == AVMEDIA_TYPE_VIDEO && !video)
+            video = st;
+        else if (st->codecpar->codec_type == AVMEDIA_TYPE_AUDIO && !audio)
+            audio = st;
+        else
+            return AVERROR(EINVAL);
+    }
+    if (!video || video->codecpar->codec_id != AV_CODEC_ID_MOBICLIP ||
+        video->codecpar->width <= 0 || video->codecpar->height <= 0 ||
+        (video->codecpar->width | video->codecpar->height) & 15) {
+        av_log(s, AV_LOG_ERROR,
+               "MOC5 requires one MobiClip video stream with dimensions divisible by 16.\n");
+        return AVERROR(EINVAL);
+    }
+    if (audio) {
+        int channels = audio->codecpar->ch_layout.nb_channels;
+        if (channels < 1 || channels > 2 || audio->codecpar->sample_rate <= 0)
+            return AVERROR(EINVAL);
+        if (audio->codecpar->codec_id != AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII) {
+            av_log(s, AV_LOG_ERROR,
+                   "MOC5 muxing supports only Wii MobiClip ADPCM audio.\n");
+            return AVERROR(EINVAL);
+        }
+        c->audio_stream = audio->index;
+        c->audio_sample_rate = audio->codecpar->sample_rate;
+        c->audio_block = MOC5_ADPCM_BYTES * channels;
+        c->audio_samples_per_block = MOC5_ADPCM_SAMPLES;
+    } else {
+        c->audio_stream = -1;
+    }
+
+    if ((ret = load_certificate(s)) < 0 ||
+        (ret = make_header(s, video, audio)) < 0)
+        return ret;
+
+    avio_write(s->pb, c->header, c->header_size);
+    c->file_end = avio_tell(s->pb);
+    if (!c->has_certificate)
+        av_log(s, AV_LOG_WARNING,
+               "Writing a MOC5 file without a pc certificate; "
+               "players which require one may reject it.\n");
+    return 0;
+}
+
+static int append_packet(uint8_t **buffer, int *buffer_size, const AVPacket *pkt)
+{
+    uint8_t *new_buffer;
+
+    if (!pkt->size)
+        return 0;
+    if (pkt->size > INT_MAX - *buffer_size)
+        return AVERROR(ENOMEM);
+    new_buffer = av_realloc(*buffer, *buffer_size + pkt->size);
+    if (!new_buffer)
+        return AVERROR(ENOMEM);
+    memcpy(new_buffer + *buffer_size, pkt->data, pkt->size);
+    *buffer = new_buffer;
+    *buffer_size += pkt->size;
+    return 0;
+}
+
+static int append_keyframe(MOC5MuxContext *c, uint32_t offset, uint32_t frame)
+{
+    uint32_t *new_offsets, *new_frames;
+    int new_capacity;
+
+    if (c->key_count >= UINT16_MAX / 2)
+        return AVERROR(ERANGE);
+    if (c->key_count == c->key_capacity) {
+        new_capacity = c->key_capacity ? 2 * c->key_capacity : 64;
+        new_capacity = FFMIN(new_capacity, UINT16_MAX / 2);
+        new_offsets = av_realloc_array(c->key_offset, new_capacity,
+                                       sizeof(*c->key_offset));
+        if (!new_offsets)
+            return AVERROR(ENOMEM);
+        c->key_offset = new_offsets;
+        new_frames = av_realloc_array(c->key_frame, new_capacity,
+                                      sizeof(*c->key_frame));
+        if (!new_frames)
+            return AVERROR(ENOMEM);
+        c->key_frame = new_frames;
+        c->key_capacity = new_capacity;
+    }
+    c->key_offset[c->key_count] = offset;
+    c->key_frame[c->key_count]  = frame;
+    c->key_count++;
+    return 0;
+}
+
+static int flush_video_chunk(AVFormatContext *s)
+{
+    MOC5MuxContext *c = s->priv_data;
+    AVIOContext *pb = s->pb;
+    int64_t chunk_start;
+    int64_t target_blocks = 0, blocks_this_chunk = 0;
+    unsigned video_size, audio_write_size = 0, audio_available = 0;
+    unsigned full_size, chunk_size, tail;
+    int fold_audio;
+    int ret;
+
+    if (!c->video)
+        return 0;
+    if (c->frame_count == UINT32_MAX)
+        return AVERROR(ERANGE);
+    if (c->video_size > INT_MAX - 3)
+        return AVERROR(ERANGE);
+
+    if (c->audio_stream >= 0) {
+        int64_t numerator = (int64_t)c->audio_sample_rate * 256;
+        int64_t denominator = (int64_t)c->fps_fixed * c->audio_samples_per_block;
+
+        target_blocks = av_rescale_rnd(c->frame_count + 1LL, numerator,
+                                       denominator, AV_ROUND_UP);
+        if (target_blocks < c->audio_blocks_emitted ||
+            target_blocks - c->audio_blocks_emitted > INT_MAX / c->audio_block)
+            return AVERROR(ERANGE);
+        blocks_this_chunk = target_blocks - c->audio_blocks_emitted;
+        audio_write_size = blocks_this_chunk * c->audio_block;
+        audio_available = FFMIN((unsigned)c->audio_size, audio_write_size);
+        if (audio_available < audio_write_size && !c->underflow_logged) {
+            av_log(s, AV_LOG_WARNING,
+                   "MOC5 audio underflow at video frame %"PRIu32"; padding missing "
+                   "fixed blocks with silence.\n",
+                   c->frame_count);
+            c->underflow_logged = 1;
+        }
+    }
+
+    video_size = FFALIGN(c->video_size, 4);
+    if (video_size > UINT_MAX - audio_write_size - 8)
+        return AVERROR(ERANGE);
+    chunk_start = avio_tell(pb);
+    if (chunk_start < 0)
+        return AVERROR(EIO);
+    full_size = 8 + video_size + audio_write_size;
+    if (full_size > INT64_MAX - chunk_start)
+        return AVERROR(ERANGE);
+    tail = 4 - ((chunk_start + full_size) & 3);
+    fold_audio = !!audio_write_size;
+    if (fold_audio && !c->frame_count && tail == 4)
+        tail = 3;
+    chunk_size = fold_audio ? full_size - tail : full_size;
+
+    if (!c->frame_count || c->video_key) {
+        if (chunk_start > UINT_MAX)
+            return AVERROR(ERANGE);
+        if ((ret = append_keyframe(c, chunk_start, c->frame_count)) < 0)
+            return ret;
+    }
+
+    avio_wl32(pb, chunk_size);
+    avio_wl32(pb, video_size);
+    avio_write(pb, c->video, c->video_size);
+    for (unsigned i = c->video_size; i < video_size; i++)
+        avio_w8(pb, 0);
+    if (audio_available)
+        avio_write(pb, c->audio, audio_available);
+    for (unsigned i = audio_available; i < audio_write_size; i++)
+        avio_w8(pb, 0);
+    if (!fold_audio)
+        for (unsigned i = 0; i < tail; i++)
+            avio_w8(pb, 0);
+
+    c->max_chunk_size = FFMAX(c->max_chunk_size, chunk_size);
+    c->audio_blocks_emitted = target_blocks;
+    c->frame_count++;
+    c->file_end = avio_tell(pb);
+    av_freep(&c->video);
+    c->video_size = 0;
+    if (audio_available < c->audio_size) {
+        memmove(c->audio, c->audio + audio_available,
+                c->audio_size - audio_available);
+        c->audio_size -= audio_available;
+    } else {
+        av_freep(&c->audio);
+        c->audio_size = 0;
+    }
+    c->video_key = 0;
+    return 0;
+}
+
+static int moc5_write_packet(AVFormatContext *s, AVPacket *pkt)
+{
+    MOC5MuxContext *c = s->priv_data;
+    int ret;
+
+    if (pkt->stream_index < 0 || pkt->stream_index >= s->nb_streams)
+        return AVERROR(EINVAL);
+    if (s->streams[pkt->stream_index]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
+        if (c->video && (ret = flush_video_chunk(s)) < 0)
+            return ret;
+        if (pkt->size >= 4 && !pkt->data[0] && !pkt->data[1] &&
+            (pkt->data[2] == 1 || (!pkt->data[2] && pkt->data[3] == 1))) {
+            av_log(s, AV_LOG_ERROR,
+                   "MOC5 expects raw MobiClip packets, not Annex-B wrapped data.\n");
+            return AVERROR_INVALIDDATA;
+        }
+        if (pkt->size <= 0)
+            return AVERROR_INVALIDDATA;
+        c->video = av_memdup(pkt->data, pkt->size);
+        if (!c->video)
+            return AVERROR(ENOMEM);
+        c->video_size = pkt->size;
+        c->video_key = !!(pkt->flags & AV_PKT_FLAG_KEY);
+        if (c->audio_stream < 0)
+            return flush_video_chunk(s);
+        return 0;
+    }
+
+    if (pkt->stream_index != c->audio_stream)
+        return AVERROR(EINVAL);
+    if (pkt->size % c->audio_block) {
+        av_log(s, AV_LOG_ERROR,
+               "MOC5 audio packet size %d is not a multiple of block size %d.\n",
+               pkt->size, c->audio_block);
+        return AVERROR_INVALIDDATA;
+    }
+    return append_packet(&c->audio, &c->audio_size, pkt);
+}
+
+static int finalize_header(AVFormatContext *s)
+{
+    MOC5MuxContext *c = s->priv_data;
+    uint8_t *new_header;
+    int prefix_size = c->index_offset + 4;
+    int index_payload_size;
+    int new_header_size;
+    int ret;
+
+    if (c->key_count > UINT16_MAX / 2)
+        return AVERROR(ERANGE);
+    index_payload_size = 8 * c->key_count;
+    if (c->header_size > INT_MAX - index_payload_size ||
+        c->file_end > INT64_MAX - index_payload_size)
+        return AVERROR(ERANGE);
+    new_header_size = c->header_size + index_payload_size;
+    new_header = av_malloc(new_header_size);
+    if (!new_header)
+        return AVERROR(ENOMEM);
+
+    memcpy(new_header, c->header, prefix_size);
+    memcpy(new_header + prefix_size + index_payload_size,
+           c->header + prefix_size, c->header_size - prefix_size);
+    av_free(c->header);
+    c->header = new_header;
+    c->header_size = new_header_size;
+    AV_WL16(c->header + c->index_offset, MOC5_TAG_KEY_INDEX);
+    AV_WL16(c->header + c->index_offset + 2, 2 * c->key_count);
+    for (int i = 0; i < c->key_count; i++) {
+        if (c->key_offset[i] > UINT32_MAX - index_payload_size)
+            return AVERROR(ERANGE);
+        AV_WL32(c->header + prefix_size + 8 * i,
+                c->key_offset[i] + index_payload_size);
+        AV_WL32(c->header + prefix_size + 8 * i + 4, c->key_frame[i]);
+    }
+    AV_WL32(c->header + 4, c->header_size - 8);
+    AV_WL32(c->header + c->frame_count_offset, c->frame_count);
+    AV_WL32(c->header + c->max_chunk_offset, c->max_chunk_size);
+    if (index_payload_size) {
+        if (avio_seek(s->pb, c->file_end, SEEK_SET) < 0)
+            return AVERROR(EIO);
+        if ((ret = ff_format_shift_data(s, prefix_size, index_payload_size)) < 0)
+            return ret;
+        c->file_end += index_payload_size;
+    }
+    if (avio_seek(s->pb, 0, SEEK_SET) < 0)
+        return AVERROR(EIO);
+    avio_write(s->pb, c->header, c->header_size);
+    if (avio_seek(s->pb, c->file_end, SEEK_SET) < 0)
+        return AVERROR(EIO);
+    return 0;
+}
+
+static int moc5_write_trailer(AVFormatContext *s)
+{
+    MOC5MuxContext *c = s->priv_data;
+    int ret = flush_video_chunk(s);
+
+    if (ret >= 0)
+        ret = finalize_header(s);
+    if (ret >= 0)
+        avio_wl32(s->pb, 0);
+    if (c->audio_size)
+        av_log(s, AV_LOG_WARNING,
+               "Discarding %d surplus MOC5 audio bytes beyond the video timeline.\n",
+               c->audio_size);
+    return ret;
+}
+
+static void moc5_deinit(AVFormatContext *s)
+{
+    MOC5MuxContext *c = s->priv_data;
+
+    av_freep(&c->header);
+    av_freep(&c->key_offset);
+    av_freep(&c->key_frame);
+    av_freep(&c->video);
+    av_freep(&c->audio);
+    c->header_size = 0;
+    c->key_count = c->key_capacity = 0;
+    c->video_size = c->audio_size = 0;
+    memset(c->certificate, 0, sizeof(c->certificate));
+    c->has_certificate = 0;
+}
+
+#define OFFSET(x) offsetof(MOC5MuxContext, x)
+#define ENC AV_OPT_FLAG_ENCODING_PARAM
+static const AVOption moc5_options[] = {
+    { "moc5_certificate", "160-byte MOC5 pc certificate file",
+      OFFSET(certificate_path), AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, ENC },
+    { NULL }
+};
+
+static const AVClass moc5_muxer_class = {
+    .class_name = "MOC5 muxer",
+    .item_name  = av_default_item_name,
+    .option     = moc5_options,
+    .version    = LIBAVUTIL_VERSION_INT,
+};
+
+const FFOutputFormat ff_mobiclip_mo_muxer = {
+    .p.name         = "mobiclip_mo",
+    .p.long_name    = NULL_IF_CONFIG_SMALL("MobiClip MOC5"),
+    .p.extensions   = "mo",
+    .p.audio_codec  = AV_CODEC_ID_ADPCM_IMA_MOBICLIP_WII,
+    .p.video_codec  = AV_CODEC_ID_MOBICLIP,
+    .p.priv_class   = &moc5_muxer_class,
+    .priv_data_size = sizeof(MOC5MuxContext),
+    .write_header   = moc5_write_header,
+    .write_packet   = moc5_write_packet,
+    .write_trailer  = moc5_write_trailer,
+    .deinit         = moc5_deinit,
+    .p.flags        = AVFMT_TS_NONSTRICT,
+};
diff --git a/libavformat/version.h b/libavformat/version.h
index 4bde82abb4..70c554c19c 100644
--- a/libavformat/version.h
+++ b/libavformat/version.h
@@ -31,7 +31,7 @@
 
 #include "version_major.h"
 
-#define LIBAVFORMAT_VERSION_MINOR   6
+#define LIBAVFORMAT_VERSION_MINOR   7
 #define LIBAVFORMAT_VERSION_MICRO 100
 
 #define LIBAVFORMAT_VERSION_INT AV_VERSION_INT(LIBAVFORMAT_VERSION_MAJOR, \
-- 
2.52.0


>From 8e1c5775fd82d1c84ea41d4b6ce48d322361fcce Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Fri, 31 Jul 2026 00:16:28 +0200
Subject: [PATCH 06/10] avcodec/mobiclipenc: add fixed-quantizer intra encoder

Add a serial MobiClip video encoder for macroblock-aligned yuv420p input.

Implement MOC5 and Moflex intra-frame syntax, transform and quantization, coefficient coding, intra-mode selection, reconstruction, and portable C DSP primitives. Start with explicit fixed-quantizer operation and deterministic packet output.

Add generated encode, mux, demux, and decode coverage using the existing MobiClip decoder, including intra-mode, selector, quantizer, certificate, and Wii ADPCM integration tests.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 Changelog                         |    1 +
 MAINTAINERS                       |    1 +
 configure                         |    1 +
 doc/encoders.texi                 |   19 +
 doc/general_contents.texi         |    1 +
 libavcodec/Makefile               |    2 +
 libavcodec/allcodecs.c            |    1 +
 libavcodec/mobiclipenc.c          | 1770 +++++++++++++++++++++++++++++
 libavcodec/mobiclipencdsp.c       |  286 +++++
 libavcodec/mobiclipencdsp.h       |   54 +
 libavcodec/mobiclipencintrapred.c |   84 ++
 libavcodec/mobiclipencintrapred.h |   46 +
 tests/Makefile                    |    1 +
 tests/fate-run.sh                 |    5 +
 tests/fate/mobiclip.mak           |   43 +
 tests/mobiclip-fate.sh            |  396 +++++++
 tests/moc5check.sh                |  270 +++++
 17 files changed, 2981 insertions(+)
 create mode 100644 libavcodec/mobiclipenc.c
 create mode 100644 libavcodec/mobiclipencdsp.c
 create mode 100644 libavcodec/mobiclipencdsp.h
 create mode 100644 libavcodec/mobiclipencintrapred.c
 create mode 100644 libavcodec/mobiclipencintrapred.h
 create mode 100644 tests/fate/mobiclip.mak
 create mode 100644 tests/mobiclip-fate.sh
 create mode 100644 tests/moc5check.sh

diff --git a/Changelog b/Changelog
index 5c53de891d..159553617e 100644
--- a/Changelog
+++ b/Changelog
@@ -11,6 +11,7 @@ version <next>:
 - DVD-Audio LPCM decoder and demuxing support
 - Wii MobiClip IMA ADPCM encoder and decoder
 - Wii MOC5 muxer and demuxer
+- MobiClip video encoder
 
 
 version 9.0:
diff --git a/MAINTAINERS b/MAINTAINERS
index 2617d2aebc..7fe48a5eee 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -224,6 +224,7 @@ Codecs:
   mjpeg*.c                              Michael Niedermayer
   mlp*                                  Ramiro Polla, Jai Luthra
   mmvideo.c                             Peter Ross
+  mobiclip*                             Martijn Brouwer
   mpeg12.c, mpeg12data.h                Michael Niedermayer
   mpegvideo.c, mpegvideo.h              Michael Niedermayer
   mqc*                                  Nicolas Bertrand
diff --git a/configure b/configure
index 61a948d14d..e672f579ae 100755
--- a/configure
+++ b/configure
@@ -3224,6 +3224,7 @@ mjpegb_decoder_select="hpeldsp mjpeg_decoder"
 mlp_decoder_select="mlp_parser"
 mlp_encoder_select="lpc audio_frame_queue"
 mobiclip_decoder_select="bswapdsp golomb"
+mobiclip_encoder_select="golomb"
 motionpixels_decoder_select="bswapdsp"
 mp1_decoder_select="mpegaudio"
 mp1float_decoder_select="mpegaudio"
diff --git a/doc/encoders.texi b/doc/encoders.texi
index 605638e040..9cbc6e6e52 100644
--- a/doc/encoders.texi
+++ b/doc/encoders.texi
@@ -3555,6 +3555,25 @@ Hardware-accelerated decoding, HW scaling and encoding with quality setting:
 ffmpeg -hwaccel d3d11va -hwaccel_output_format d3d11 -i input.mp4 -vf scale_d3d11=1920:1080 -c:v hevc_mf -hw_encoding 1 -quality 80 output.mp4
 @end example
 
+@section MobiClip
+
+The @code{mobiclip} encoder produces MobiClip elementary video for the Wii MO
+and Moflex profiles. Input must be @code{yuv420p}, and both dimensions must be
+multiples of 16.
+
+@subsection Options
+
+@table @option
+@item qp @var{integer}
+Set a fixed quantizer written directly to the MobiClip bitstream. The valid
+range is 12 to 63. Lower values retain more detail and usually produce larger
+files. When no quantizer is set, the encoder uses quantizer 40.
+
+@item profile @var{profile}
+Select @code{moc5} for the Wii MO bitstream profile or @code{moflex} for the
+flexible profile. The default is @code{moflex}.
+@end table
+
 @section Microsoft RLE
 
 Microsoft RLE aka MSRLE encoder.
diff --git a/doc/general_contents.texi b/doc/general_contents.texi
index 9d606a2d13..6de1cb3647 100644
--- a/doc/general_contents.texi
+++ b/doc/general_contents.texi
@@ -1118,6 +1118,7 @@ following image formats are supported:
 @item Miro VideoXL           @tab     @tab  X
     @tab fourcc: VIXL
 @item MJPEG (Motion JPEG)    @tab  X  @tab  X
+@item MobiClip video         @tab  X  @tab  X
 @item Mobotix MxPEG video    @tab     @tab  X
 @item Motion Pixels video    @tab     @tab  X
 @item MPEG-1 video           @tab  X  @tab  X
diff --git a/libavcodec/Makefile b/libavcodec/Makefile
index d3840626bd..65038f4b0b 100644
--- a/libavcodec/Makefile
+++ b/libavcodec/Makefile
@@ -546,6 +546,8 @@ OBJS-$(CONFIG_MLP_DECODER)             += mlpdec.o mlpdsp.o
 OBJS-$(CONFIG_MLP_ENCODER)             += mlpenc.o mlp.o
 OBJS-$(CONFIG_MMVIDEO_DECODER)         += mmvideo.o
 OBJS-$(CONFIG_MOBICLIP_DECODER)        += mobiclip.o mobiclipdata.o
+OBJS-$(CONFIG_MOBICLIP_ENCODER)        += mobiclipenc.o mobiclipencdsp.o \
+                                          mobiclipencintrapred.o mobiclipdata.o
 OBJS-$(CONFIG_MOTIONPIXELS_DECODER)    += motionpixels.o
 OBJS-$(CONFIG_MOVTEXT_DECODER)         += movtextdec.o ass.o
 OBJS-$(CONFIG_MOVTEXT_ENCODER)         += movtextenc.o ass_split.o
diff --git a/libavcodec/allcodecs.c b/libavcodec/allcodecs.c
index 7a568909a0..257d0cc733 100644
--- a/libavcodec/allcodecs.c
+++ b/libavcodec/allcodecs.c
@@ -202,6 +202,7 @@ extern const FFCodec ff_mjpeg_decoder;
 extern const FFCodec ff_mjpegb_decoder;
 extern const FFCodec ff_mmvideo_decoder;
 extern const FFCodec ff_mobiclip_decoder;
+extern const FFCodec ff_mobiclip_encoder;
 extern const FFCodec ff_motionpixels_decoder;
 extern const FFCodec ff_mpeg1video_encoder;
 extern const FFCodec ff_mpeg1video_decoder;
diff --git a/libavcodec/mobiclipenc.c b/libavcodec/mobiclipenc.c
new file mode 100644
index 0000000000..5919df6735
--- /dev/null
+++ b/libavcodec/mobiclipenc.c
@@ -0,0 +1,1770 @@
+/*
+ * MobiClip video encoder
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include <math.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <string.h>
+
+#include "libavutil/common.h"
+#include "libavutil/imgutils.h"
+#include "libavutil/internal.h"
+#include "libavutil/mem.h"
+#include "libavutil/opt.h"
+
+#include "avcodec.h"
+#include "codec_internal.h"
+#include "encode.h"
+#include "internal.h"
+#include "mathops.h"
+#include "mobiclipdata.h"
+#include "mobiclipencdsp.h"
+#include "mobiclipencintrapred.h"
+#include "profiles.h"
+#include "put_bits.h"
+#include "put_golomb.h"
+
+typedef struct MobiEncBlock {
+    int16_t level[64];
+    int has_coeffs;
+} MobiEncBlock;
+
+typedef struct MobiEncUnit {
+    MobiEncBlock block[4];
+    unsigned mask;
+    int transform8;
+} MobiEncUnit;
+
+#define MOBI_COST_INF 0x10000000
+#define MOBI_QP_BITS 6
+#define MOBI_QP_PERIOD 6
+#define MOBI_MIN_QP 12
+#define MOBI_MAX_QP ((1 << MOBI_QP_BITS) - 1)
+#define MOBI_DEFAULT_QP 40
+#define MOBI_RL_COST_FULL_ESCAPE 63
+#define MOBI_RL_COST_MAGNITUDES (MOBI_RL_COST_FULL_ESCAPE + 1)
+#define MOBI_NEUTRAL_CHROMA (1 << 7)
+#define MOBI_LAMBDA_SCALE 0.035
+#define MOBI_QP_EXPONENT_STEP (M_LN2 / 3.0)
+
+typedef struct MobiEncMacroblockPixels {
+    uint8_t luma[16 * 16];
+    uint8_t chroma[2][8 * 8];
+} MobiEncMacroblockPixels;
+
+typedef struct MobiIntraUnit {
+    MobiEncUnit residual;
+    int mode[4];
+    int predicted_mode[4];
+    int plane_delta[4];
+    int split4;
+} MobiIntraUnit;
+
+typedef struct MobiIntraMacroblock {
+    MobiIntraUnit luma[4];
+    MobiIntraUnit chroma[2];
+    MobiEncMacroblockPixels pixels;
+    unsigned component_mask;
+    int luma_mode;
+    int chroma_mode;
+    int luma_plane_delta;
+    int chroma_plane_delta[2];
+    int predicted;
+    int cost;
+} MobiIntraMacroblock;
+
+typedef struct MobiClipEncContext {
+    const AVClass *class;
+    AVFrame *recon;
+    int qp;
+    int lambda;
+    int motion_lambda;
+    int qdiv;
+    int qrem;
+    int moflex;
+    int qtab[16];
+    int16_t qmul4[16];
+    int16_t qmul8[64];
+    int16_t dequant8[64];
+    uint16_t rl_code[2][1 << 12];
+    uint8_t rl_len[2][1 << 12];
+    uint8_t rl_level_residue[2][2][64];
+    uint8_t rl_run_residue[2][2][32];
+    uint8_t rl_cost[2][2][64][MOBI_RL_COST_MAGNITUDES];
+    uint8_t intra_context[20];
+    MobiClipEncDSPContext dsp;
+    MobiClipEncIntraPredContext intra_pred;
+    AVFrame *input;
+} MobiClipEncContext;
+
+static uint64_t mobi_lambda(int qp)
+{
+    return (uint64_t)(FFMAX(1.0, MOBI_LAMBDA_SCALE *
+                                 exp(qp * MOBI_QP_EXPONENT_STEP)) + 0.5);
+}
+
+static int mobi_motion_lambda(int qp)
+{
+    /*
+     * Motion estimation independently rounds the square root of the
+     * unrounded RDO lambda.
+     */
+    return (int)(sqrt(FFMAX(1.0, MOBI_LAMBDA_SCALE *
+                                exp(qp * MOBI_QP_EXPONENT_STEP))) + 0.5);
+}
+
+static void mobi_set_qp(MobiClipEncContext *s, int qp)
+{
+    s->qp = qp;
+    s->lambda = mobi_lambda(qp);
+    s->motion_lambda = mobi_motion_lambda(qp);
+    s->qdiv = qp / MOBI_QP_PERIOD;
+    s->qrem = qp % MOBI_QP_PERIOD;
+}
+
+/*
+ * Motion-tree VLCs, in width-major partition order:
+ * 16x16, 16x8, 16x4, 16x2, 8x16, ..., 2x2.  Symbols 6 and 7
+ * are the P-picture intra modes and are not legal below the root.
+ */
+
+static const uint8_t mobi_8x8_category[64] = {
+    0, 1, 2, 1, 0, 1, 2, 1,
+    1, 3, 4, 3, 1, 3, 4, 3,
+    2, 4, 5, 4, 2, 4, 5, 4,
+    1, 3, 4, 3, 1, 3, 4, 3,
+    0, 1, 2, 1, 0, 1, 2, 1,
+    1, 3, 4, 3, 1, 3, 4, 3,
+    2, 4, 5, 4, 2, 4, 5, 4,
+    1, 3, 4, 3, 1, 3, 4, 3,
+};
+
+static const int16_t mobi_mf8[MOBI_QP_PERIOD][6] = {
+    { 13107, 12222, 16777, 11428, 15481, 20972 },
+    { 11916, 11058, 14980, 10826, 14290, 19174 },
+    { 10082,  9675, 12710,  8943, 11985, 15978 },
+    {  9362,  8931, 11984,  8228, 11259, 14913 },
+    {  8192,  7740, 10486,  7346,  9777, 13159 },
+    {  7282,  6830,  9118,  6428,  8640, 11570 },
+};
+
+static const int16_t mobi_dequant8[MOBI_QP_PERIOD][6] = {
+    { 20, 19, 25, 18, 24, 32 },
+    { 22, 21, 28, 19, 26, 35 },
+    { 26, 24, 33, 23, 31, 42 },
+    { 28, 26, 35, 25, 33, 45 },
+    { 32, 30, 40, 28, 38, 51 },
+    { 36, 34, 46, 32, 43, 58 },
+};
+
+static const uint8_t mobi_4x4_category[16] = {
+    0, 1, 0, 1,
+    1, 2, 1, 2,
+    0, 1, 0, 1,
+    1, 2, 1, 2,
+};
+
+static const int16_t mobi_mf4[MOBI_QP_PERIOD][3] = {
+    { 13107, 8066, 5243 },
+    { 11916, 7490, 4660 },
+    { 10082, 6554, 4194 },
+    {  9362, 5825, 3647 },
+    {  8192, 5243, 3355 },
+    {  7282, 4559, 2893 },
+};
+
+/* QP 12..53, indexed after clipping to that table range. */
+static const int mobi_zero_threshold8[] = {
+       3,     3,     3,     3,     3,    17,    19,    24,
+      31,    36,    40,    40,    46,    49,   116,   126,
+     156,   168,   180,   340,   364,   391,   623,   668,
+     981,  1050,  1450,  1931,  2498,  3156,  3918,  4798,
+    6514,  7726,  9991, 12630, 15680, 19185, 26056, 30901,
+   39963, 50517,
+};
+
+static const int mobi_zero_threshold4[] = {
+       9,     9,    10,    10,    10,    10,    10,    13,
+      28,    33,    40,    40,    46,    85,    91,    97,
+     156,   168,   244,   262,   364,   483,   623,   789,
+     981,  1199,  1628,  1931,  2498,  3156,  3918,  4798,
+    6514,  7726,  9991, 12630, 15680, 19185, 26056, 30901,
+   39963, 50517,
+};
+
+static int find_mask(const uint8_t *table, int count, unsigned mask)
+{
+    for (int i = 0; i < count; i++)
+        if (table[i] == mask)
+            return i;
+    return -1;
+}
+
+static int coefficient_symbol_bits(const MobiClipEncContext *s, int table,
+                                   int last, int run, int magnitude)
+{
+    if (magnitude <= 31) {
+        unsigned symbol = (last << 11) | (run << 5) | magnitude;
+
+        if (s->rl_len[table][symbol])
+            return s->rl_len[table][symbol] + 1;
+    }
+
+    {
+        int base_level = magnitude -
+                         s->rl_level_residue[table][last][run];
+
+        if ((unsigned)base_level <= 31) {
+            unsigned symbol = (last << 11) | (run << 5) | base_level;
+
+            if (s->rl_len[table][symbol])
+                return s->rl_len[table][0] + 1 +
+                       s->rl_len[table][symbol] + 1;
+        }
+    }
+
+    if (magnitude <= 31) {
+        int base_run = run -
+                       s->rl_run_residue[table][last][magnitude];
+
+        if ((unsigned)base_run <= 63) {
+            unsigned symbol = (last << 11) |
+                              (base_run << 5) | magnitude;
+
+            if (s->rl_len[table][symbol])
+                return s->rl_len[table][0] + 2 +
+                       s->rl_len[table][symbol] + 1;
+        }
+    }
+
+    return s->rl_len[table][0] + 2 + 1 + 6 + 12;
+}
+
+static int coefficient_bits(const MobiClipEncContext *s,
+                            const int16_t *level, int count, int table)
+{
+    int last_pos = -1;
+    int bits = 0;
+
+    for (int i = count - 1; i >= 0; i--)
+        if (level[i]) {
+            last_pos = i;
+            break;
+        }
+    av_assert2(last_pos >= 0);
+
+    for (int pos = 0, previous = -1; pos <= last_pos; pos++) {
+        int magnitude, run, last;
+
+        if (!level[pos])
+            continue;
+        run = pos - previous - 1;
+        previous = pos;
+        last = pos == last_pos;
+        magnitude = FFMIN(FFABS(level[pos]), MOBI_RL_COST_FULL_ESCAPE);
+        bits += s->rl_cost[table][last][run][magnitude];
+    }
+
+    return bits;
+}
+
+static void fill_block(uint8_t *dst, ptrdiff_t stride, int size, int value)
+{
+    for (int y = 0; y < size; y++) {
+        memset(dst, value, size);
+        dst += stride;
+    }
+}
+
+static int evaluate_residual4x4(const MobiClipEncContext *s,
+                                     const uint8_t *src,
+                                     ptrdiff_t src_stride,
+                                     const uint8_t *prediction,
+                                     ptrdiff_t prediction_stride,
+                                     uint8_t *recon,
+                                     ptrdiff_t recon_stride,
+                                     MobiEncBlock *block, int table)
+{
+    int threshold = mobi_zero_threshold4[
+        av_clip(s->qp, MOBI_MIN_QP,
+                MOBI_MIN_QP + FF_ARRAY_ELEMS(mobi_zero_threshold8) - 1) -
+        MOBI_MIN_QP];
+    int16_t residual[16], coeff[16];
+    int raw_sse = 0;
+
+    memset(block, 0, sizeof(*block));
+    for (int y = 0; y < 4; y++)
+        memcpy(recon + y * recon_stride,
+               prediction + y * prediction_stride, 4);
+    raw_sse = s->dsp.residual_fdct4(residual, coeff, src, src_stride,
+                                    prediction, prediction_stride);
+    if (raw_sse < threshold)
+        return raw_sse;
+
+    block->has_coeffs = s->dsp.quant4(
+        block->level, coeff, s->qmul4, s->qdiv + 3,
+        (1U << (s->qdiv + 3)) / 3);
+    if (!block->has_coeffs)
+        return raw_sse;
+
+    {
+        /*
+         * Measure distortion between the input residual and the
+         * inverse-transformed residual.  Add the prediction and clip to
+         * uint8 only after candidate selection.
+         */
+        int reconstructed_sse = s->dsp.reconstruct4x4(
+            recon, recon_stride, block->level, s->qtab, residual);
+        int coded_cost =
+            reconstructed_sse +
+            coefficient_bits(s, block->level, 16, table) *
+            s->lambda;
+
+        if (coded_cost < raw_sse)
+            return coded_cost;
+    }
+
+    block->has_coeffs = 0;
+    memset(block->level, 0, sizeof(block->level));
+    for (int y = 0; y < 4; y++)
+        memcpy(recon + y * recon_stride,
+               prediction + y * prediction_stride, 4);
+    return raw_sse;
+}
+
+static int evaluate_residual8x8(const MobiClipEncContext *s,
+                                     const uint8_t *src,
+                                     ptrdiff_t src_stride,
+                                     const uint8_t *prediction,
+                                     ptrdiff_t prediction_stride,
+                                     uint8_t recon[64],
+                                     MobiEncBlock *block, int table)
+{
+    int threshold = mobi_zero_threshold8[
+        av_clip(s->qp, MOBI_MIN_QP,
+                MOBI_MIN_QP + FF_ARRAY_ELEMS(mobi_zero_threshold4) - 1) -
+        MOBI_MIN_QP];
+    int16_t residual[64], coeff[64], natural_level[64];
+    int raw_sse = 0;
+
+    memset(block, 0, sizeof(*block));
+    for (int y = 0; y < 8; y++)
+        memcpy(recon + y * 8, prediction + y * prediction_stride, 8);
+    raw_sse = s->dsp.residual_fdct8(residual, coeff, src, src_stride,
+                                    prediction, prediction_stride);
+    if (raw_sse < threshold)
+        return raw_sse;
+
+    s->dsp.quant8(natural_level, coeff, s->qmul8, s->qdiv + 2,
+                  (1U << (s->qdiv + 2)) / 3);
+    for (int pos = 0; pos < 64; pos++) {
+        int natural = ff_zigzag_direct[pos];
+
+        block->level[pos] = natural_level[natural];
+        block->has_coeffs |= !!block->level[pos];
+    }
+    if (!block->has_coeffs)
+        return raw_sse;
+
+    {
+        /*
+         * Measure distortion in the residual domain before prediction
+         * addition and uint8 clipping.
+         */
+        int reconstructed_sse =
+            s->dsp.reconstruct8x8(recon, 8, natural_level,
+                                  s->dequant8, s->qdiv - 2, residual);
+        int coded_cost =
+            reconstructed_sse +
+            coefficient_bits(s, block->level, 64, table) *
+            s->lambda;
+
+        if (coded_cost < raw_sse)
+            return coded_cost;
+    }
+
+    block->has_coeffs = 0;
+    memset(block->level, 0, sizeof(block->level));
+    for (int y = 0; y < 8; y++)
+        memcpy(recon + y * 8, prediction + y * prediction_stride, 8);
+    return raw_sse;
+}
+
+static int mobi_ue_bits(unsigned value)
+{
+    return 2 * av_log2(value + 1) + 1;
+}
+
+static int mobi_se_bits(int value)
+{
+    unsigned symbol = value <= 0 ? -(unsigned)value * 2 :
+                                  (unsigned)value * 2 - 1;
+
+    return mobi_ue_bits(symbol);
+}
+
+static void copy_frame_block(const AVFrame *frame, int plane, int x, int y,
+                             int size, uint8_t *block)
+{
+    const uint8_t *src = frame->data[plane] +
+                         y * frame->linesize[plane] + x;
+
+    for (int row = 0; row < size; row++)
+        memcpy(block + row * size, src + row * frame->linesize[plane],
+               size);
+}
+
+static void put_frame_block(AVFrame *frame, int plane, int x, int y,
+                            int size, const uint8_t *block)
+{
+    uint8_t *dst = frame->data[plane] +
+                   y * frame->linesize[plane] + x;
+
+    for (int row = 0; row < size; row++)
+        memcpy(dst + row * frame->linesize[plane], block + row * size,
+               size);
+}
+
+static av_always_inline int intra_edge_sample(const AVFrame *frame, int plane,
+                                              int bx, int by, int size,
+                                              int x, int y)
+{
+    int width  = frame->width  >> !!plane;
+    int height = frame->height >> !!plane;
+
+    if (x == -1 && y >= size) {
+        y = size - 1;
+    } else if (x >= -1 && y >= -1) {
+        /* The coordinate can refer to an already generated block pixel. */
+    } else if (x == -1 && y == -2) {
+        x = 0;
+        y = -1;
+    } else if (x == -2 && y == -1) {
+        x = -1;
+        y = 0;
+    }
+
+    return frame->data[plane][
+        av_clip(by + y, 0, height - 1) * frame->linesize[plane] +
+        av_clip(bx + x, 0, width - 1)];
+}
+
+static int intra_half(int a, int b)
+{
+    return (a + b + 1) >> 1;
+}
+
+static int intra_filter(int a, int b, int c)
+{
+    return (a + 2 * b + c + 2) >> 2;
+}
+
+static void intra_predict_block(const MobiClipEncIntraPredContext *intra_pred,
+                                AVFrame *frame, int plane, int bx, int by,
+                                int size, int mode, int plane_delta)
+{
+    uint8_t *dst = frame->data[plane] +
+                   by * frame->linesize[plane] + bx;
+    ptrdiff_t stride = frame->linesize[plane];
+
+    if ((mode == 0 || mode == 1) && (size == 4 || size == 8)) {
+        uint8_t edge[8];
+
+        for (int i = 0; i < size; i++)
+            edge[i] = mode == 0 ?
+                      intra_edge_sample(frame, plane, bx, by, size, i, -1) :
+                      intra_edge_sample(frame, plane, bx, by, size, -1, i);
+        intra_pred->pred[size == 8](dst, stride, edge, mode, 0);
+        return;
+    }
+
+    if (mode == 2 && (size == 4 || size == 8)) {
+        uint8_t top[8], left[8];
+
+        for (int i = 0; i < size; i++) {
+            top[i] = intra_edge_sample(frame, plane, bx, by, size, i, -1);
+            left[i] = intra_edge_sample(frame, plane, bx, by, size, -1, i);
+        }
+        intra_pred->plane[size == 8](dst, stride, top, left, plane_delta);
+        return;
+    }
+
+    if (mode == 2) {
+        int top[16], left[16], a[16], b[16];
+        int bottom_left, top_right, avg, dx, dy;
+        int shift = size == 4 ? 2 : 3;
+
+        for (int i = 0; i < size; i++) {
+            top[i] = intra_edge_sample(frame, plane, bx, by, size, i, -1);
+            left[i] = intra_edge_sample(frame, plane, bx, by, size, -1, i);
+        }
+        bottom_left = left[size - 1];
+        top_right   = top[size - 1];
+        avg = intra_half(bottom_left, top_right) + 2 * plane_delta;
+        dx = avg - bottom_left;
+        dy = avg - top_right;
+        if (size == 16) {
+            dx = (dx + 1) >> 1;
+            dy = (dy + 1) >> 1;
+        }
+        for (int i = 0; i < size; i++) {
+            int av = (bottom_left - top[i]) * (1 << shift) + dx * (i + 1);
+            int bv = (top_right - left[i]) * (1 << shift) + dy * (i + 1);
+
+            if (size == 16) {
+                av = (av + 1) >> 1;
+                bv = (bv + 1) >> 1;
+            }
+            a[i] = av;
+            b[i] = bv;
+        }
+        for (int y = 0; y < size; y++)
+            for (int x = 0; x < size; x++)
+                dst[y * stride + x] =
+                    ((top[x] + left[y] +
+                      ((a[x] * (y + 1) + b[y] * (x + 1)) >>
+                       (2 * shift)) + 1) >> 1) & UINT8_MAX;
+        return;
+    }
+
+    if (mode == 3) {
+        int sum = 0;
+        int top = by > 0;
+        int left = bx > 0;
+        int fill;
+
+        if (!top && !left) {
+            fill = MOBI_NEUTRAL_CHROMA;
+        } else {
+            if (top)
+                for (int i = 0; i < size; i++)
+                    sum += frame->data[plane][
+                        (by - 1) * stride + bx + i];
+            if (left)
+                for (int i = 0; i < size; i++)
+                    sum += frame->data[plane][
+                        (by + i) * stride + bx - 1];
+            /*
+             * Match predict_intra() exactly.  Its two integer divisions are
+             * observable for half-way sums and cannot be folded into a
+             * conventional rounded average without changing pixels.
+             */
+            fill = top && left ? (sum / size + 1) / 2 :
+                                 (sum * 2 / size + 1) / 2;
+        }
+        if (size == 4 || size == 8)
+            intra_pred->pred[size == 8](dst, stride, NULL, mode, fill);
+        else
+            fill_block(dst, stride, size, fill);
+        return;
+    }
+
+    for (int y = 0; y < size; y++) {
+        for (int x = 0; x < size; x++) {
+            int value;
+
+#define P(px, py) intra_edge_sample(frame, plane, bx, by, size, px, py)
+            switch (mode) {
+            case 0:
+                value = P(x, -1);
+                break;
+            case 1:
+                value = P(-1, y);
+                break;
+            case 4:
+                value = !(x & 1) ?
+                        intra_half(P(-1, y + x / 2),
+                                   P(-1, y + x / 2 + 1)) :
+                        intra_filter(P(-1, y + x / 2),
+                                     P(-1, y + x / 2 + 1),
+                                     P(-1, y + x / 2 + 2));
+                break;
+            case 5:
+                if (!x)
+                    value = intra_half(P(-1, y - 1), P(-1, y));
+                else if (!y)
+                    value = intra_filter(P(x - 3, -1), P(x - 2, -1),
+                                         P(x - 1, -1));
+                else if (x == 1)
+                    value = intra_filter(P(-1, y - 2), P(-1, y - 1),
+                                         P(-1, y));
+                else
+                    value = P(x - 2, y - 1);
+                break;
+            case 6:
+                if (!y)
+                    value = intra_half(P(x - 1, -1), P(x, -1));
+                else if (!x)
+                    value = intra_filter(P(-1, y - 3), P(-1, y - 2),
+                                         P(-1, y - 1));
+                else if (y == 1)
+                    value = intra_filter(P(x - 2, -1), P(x - 1, -1),
+                                         P(x, -1));
+                else
+                    value = P(x - 1, y - 2);
+                break;
+            case 7:
+                value = P(x - 1, y - 1);
+                if (!x || !y) {
+                    int a = !x ? P(-1, y) : P(x - 2, -1);
+                    int c = !y ? P(x, -1) : P(-1, y - 2);
+
+                    value = intra_filter(a, value, c);
+                }
+                break;
+            case 8:
+                if (!y)
+                    value = intra_half(P(x, -1), P(x + 1, -1));
+                else if (y == 1)
+                    value = intra_filter(P(x, -1), P(x + 1, -1),
+                                         P(x + 2, -1));
+                else if (x < size - 1)
+                    value = P(x + 1, y - 2);
+                else if (!(y & 1))
+                    value = intra_half(P(size - 1 + y / 2, -1),
+                                       P(size + y / 2, -1));
+                else
+                    value = intra_filter(P(size - 1 + y / 2, -1),
+                                         P(size + y / 2, -1),
+                                         P(size + 1 + y / 2, -1));
+                break;
+            default:
+                av_unreachable("invalid MobiClip intra prediction mode");
+            }
+#undef P
+            dst[y * stride + x] = value;
+        }
+    }
+}
+
+static int intra_availability(const AVFrame *frame, int plane,
+                              int x, int y, int size,
+                              int region_x, int region_y, int region_size)
+{
+    int width = frame->width >> !!plane;
+    int flags = (y > 0) | ((x > 0) << 1);
+
+    if (x + size < region_x + region_size ||
+        (y == region_y && y > 0 && region_x + region_size < width))
+        flags |= 4;
+    return flags;
+}
+
+static const uint8_t *intra_candidates(int flags, int *count)
+{
+    static const uint8_t modes[8][9] = {
+        { 3 },
+        { 0, 3 },
+        { 1, 3, 4 },
+        { 0, 1, 2, 3, 4, 5, 6, 7 },
+        { 3 },
+        { 0, 3, 8 },
+        { 1, 3, 4 },
+        { 0, 1, 2, 3, 4, 5, 6, 7, 8 },
+    };
+    static const uint8_t counts[8] = { 1, 2, 3, 8, 1, 3, 3, 9 };
+
+    *count = counts[flags & 7];
+    return modes[flags & 7];
+}
+
+static int optimize_plane_offset(MobiClipEncContext *s, const AVFrame *src,
+                                 int plane, int x, int y, int size)
+{
+    const uint8_t *input = src->data[plane] +
+                           y * src->linesize[plane] + x;
+    /*
+     * Scale the offset-search cost by the independently rounded motion
+     * lambda.  The caller later charges the selected delta at full RDO
+     * lambda.
+     */
+    int lambda = s->motion_lambda;
+    int bottom_left = intra_edge_sample(s->recon, plane, x, y, size,
+                                        -1, size - 1);
+    int top_right = intra_edge_sample(s->recon, plane, x, y, size,
+                                      size - 1, -1);
+    int base = intra_half(bottom_left, top_right);
+    int cost[2];
+    int best_cost;
+    int best, step;
+
+    for (int delta = 0; delta <= 1; delta++) {
+        if ((unsigned)(base + 2 * delta) > UINT8_MAX) {
+            cost[delta] = MOBI_COST_INF;
+            continue;
+        }
+        intra_predict_block(&s->intra_pred, s->recon, plane,
+                            x, y, size, 2, delta);
+        cost[delta] = 0;
+        for (int by = 0; by < size; by++)
+            for (int bx = 0; bx < size; bx++)
+                cost[delta] += FFABS(
+                    input[by * src->linesize[plane] + bx] -
+                    s->recon->data[plane][
+                        (y + by) * s->recon->linesize[plane] + x + bx]);
+        cost[delta] += mobi_se_bits(delta) * lambda;
+    }
+
+    if (cost[0] < cost[1]) {
+        best = 0;
+        step = -1;
+    } else {
+        best = 1;
+        step = 1;
+    }
+    best_cost = cost[best];
+    for (int delta = best + step;
+         (unsigned)(base + 2 * delta) <= UINT8_MAX;
+         delta += step) {
+        int candidate = 0;
+
+        intra_predict_block(&s->intra_pred, s->recon, plane,
+                            x, y, size, 2, delta);
+        for (int by = 0; by < size; by++)
+            for (int bx = 0; bx < size; bx++)
+                candidate += FFABS(
+                    input[by * src->linesize[plane] + bx] -
+                    s->recon->data[plane][
+                        (y + by) * s->recon->linesize[plane] + x + bx]);
+        candidate += mobi_se_bits(delta) * lambda;
+        if (best_cost <= candidate)
+            break;
+        best = delta;
+        best_cost = candidate;
+    }
+    intra_predict_block(&s->intra_pred, s->recon, plane,
+                        x, y, size, 2, best);
+    return best;
+}
+
+static int evaluate_fixed_intra_unit(MobiClipEncContext *s,
+                                     const AVFrame *src, int plane,
+                                     int x, int y, int table, int mode,
+                                     int bound, MobiIntraUnit *result)
+{
+    const uint8_t *input = src->data[plane] +
+                           y * src->linesize[plane] + x;
+    uint8_t original[64], prediction[64], recon8[64], recon4[64];
+    MobiEncUnit split = { 0 };
+    MobiEncBlock block8;
+    int lambda = s->lambda;
+    int cost8, cost4 = 0;
+    int split_bound;
+    int split_valid = 1;
+
+    copy_frame_block(s->recon, plane, x, y, 8, original);
+    if (mode != 9)
+        intra_predict_block(&s->intra_pred, s->recon, plane,
+                            x, y, 8, mode, 0);
+    copy_frame_block(s->recon, plane, x, y, 8, prediction);
+    cost8 = evaluate_residual8x8(s, input, src->linesize[plane],
+                                 prediction, 8, recon8, &block8, table);
+    if (block8.has_coeffs)
+        cost8 += lambda;
+
+    /*
+     * Evaluate the direct transform first and use its cost to tighten the
+     * split bound.  Equality keeps the direct result.
+     */
+    split_bound = FFMIN(bound, cost8);
+    put_frame_block(s->recon, plane, x, y, 8, original);
+    for (int by = 0, n = 0; by < 8 && split_valid; by += 4) {
+        for (int bx = 0; bx < 8; bx += 4, n++) {
+            uint8_t prediction4[16], reconstructed4[16];
+            int child_cost;
+
+            if (mode != 9)
+                intra_predict_block(&s->intra_pred, s->recon, plane,
+                                    x + bx, y + by, 4, mode, 0);
+            copy_frame_block(s->recon, plane, x + bx, y + by,
+                             4, prediction4);
+            child_cost = evaluate_residual4x4(
+                s, input + by * src->linesize[plane] + bx,
+                src->linesize[plane], prediction4, 4,
+                reconstructed4, 4, &split.block[n], table);
+            if (child_cost >= split_bound - cost4) {
+                split_valid = 0;
+                break;
+            }
+            cost4 += child_cost;
+            if (split.block[n].has_coeffs)
+                split.mask |= 1U << n;
+            put_frame_block(s->recon, plane, x + bx, y + by,
+                            4, reconstructed4);
+        }
+    }
+    if (split_valid)
+        copy_frame_block(s->recon, plane, x, y, 8, recon4);
+    if (split_valid) {
+        int index = find_mask(ff_mobiclip_i4_masks,
+                              FF_ARRAY_ELEMS(ff_mobiclip_i4_masks),
+                              split.mask);
+        int syntax_cost;
+
+        av_assert2(index >= 0);
+        syntax_cost = mobi_ue_bits(index + 1) * lambda;
+        if (syntax_cost >= split_bound - cost4)
+            split_valid = 0;
+        else
+            cost4 += syntax_cost;
+    }
+
+    memset(result, 0, sizeof(*result));
+    if (split_valid && cost4 < cost8) {
+        result->residual = split;
+        result->split4 = 1;
+        put_frame_block(s->recon, plane, x, y, 8, recon4);
+        return cost4;
+    }
+
+    if (cost8 > bound) {
+        put_frame_block(s->recon, plane, x, y, 8, original);
+        return MOBI_COST_INF;
+    }
+    result->residual.block[0] = block8;
+    result->residual.mask = block8.has_coeffs;
+    result->residual.transform8 = block8.has_coeffs;
+    put_frame_block(s->recon, plane, x, y, 8, recon8);
+    return cost8;
+}
+
+static int predicted_mode(const uint8_t context[20], int x, int y)
+{
+    int index = (y & 12) | ((x >> 2) & 3);
+    int mode = FFMIN(context[index],
+                     (index & 3) ? context[index + 3] : 9);
+
+    return mode == 9 ? 3 : mode;
+}
+
+static void commit_predicted_mode(uint8_t context[20], int x, int y,
+                                  int size, int mode)
+{
+    int index = (y & 12) | ((x >> 2) & 3);
+
+    context[index + 4] = mode;
+    if (size == 8)
+        context[index + 5] = context[index + 8] =
+        context[index + 9] = mode;
+}
+
+static int search_predicted_block(MobiClipEncContext *s,
+                                  const AVFrame *src, int x, int y,
+                                  int size, int availability,
+                                  int prediction, int table, int bound,
+                                  MobiEncBlock *block,
+                                  int *mode, int *plane_delta)
+{
+    const uint8_t *input = src->data[0] + y * src->linesize[0] + x;
+    uint8_t original[64], best_pixels[64], recon[64];
+    const uint8_t *modes;
+    int lambda = s->lambda;
+    int best_cost = bound;
+    int found = 0;
+    int count;
+
+    copy_frame_block(s->recon, 0, x, y, size, original);
+    modes = intra_candidates(availability, &count);
+    for (int i = 0; i < count; i++) {
+        MobiEncBlock candidate;
+        int cost;
+        int candidate_mode = modes[i];
+        int delta = 0;
+
+        put_frame_block(s->recon, 0, x, y, size, original);
+        if (candidate_mode == 2)
+            delta = optimize_plane_offset(s, src, 0, x, y, size);
+        else
+            intra_predict_block(&s->intra_pred, s->recon, 0,
+                                x, y, size, candidate_mode, 0);
+
+        if (size == 8) {
+            uint8_t prediction_pixels[64];
+
+            copy_frame_block(s->recon, 0, x, y, 8, prediction_pixels);
+            cost = evaluate_residual8x8(
+                s, input, src->linesize[0], prediction_pixels, 8,
+                recon, &candidate, table);
+        } else {
+            uint8_t prediction_pixels[16];
+
+            copy_frame_block(s->recon, 0, x, y, 4, prediction_pixels);
+            cost = evaluate_residual4x4(
+                s, input, src->linesize[0], prediction_pixels, 4,
+                recon, 4, &candidate, table);
+        }
+        cost += (candidate_mode == prediction ? 1 : 4) * lambda;
+        if (candidate_mode == 2)
+            cost += mobi_se_bits(delta) * lambda;
+
+        if (cost < best_cost) {
+            found = 1;
+            best_cost = cost;
+            *block = candidate;
+            *mode = candidate_mode;
+            *plane_delta = delta;
+            memcpy(best_pixels, recon, size * size);
+        }
+    }
+    if (!found) {
+        put_frame_block(s->recon, 0, x, y, size, original);
+        return MOBI_COST_INF;
+    }
+    put_frame_block(s->recon, 0, x, y, size, best_pixels);
+    /*
+     * Compare candidates before charging the coded-8x8 selector, then charge
+     * it exactly once after installing the winner.  Charging it during the
+     * search changes tie ordering.
+     */
+    if (size == 8 && block->has_coeffs)
+        best_cost += lambda;
+    return best_cost;
+}
+
+static int evaluate_predicted_intra_unit(
+    MobiClipEncContext *s, const AVFrame *src, int x, int y,
+    int region_x, int region_y, int region_size, uint8_t context[20],
+    int table, int bound, MobiIntraUnit *result)
+{
+    MobiIntraUnit unit8 = { 0 }, unit4 = { 0 };
+    uint8_t original[64], pixels8[64], pixels4[64];
+    uint8_t context_start[20], context8[20], context4[20];
+    int lambda = s->lambda;
+    int cost8, cost4 = 0;
+    int split_bound;
+    int split_valid = 1;
+    int availability, prediction, unit_availability;
+
+    copy_frame_block(s->recon, 0, x, y, 8, original);
+    memcpy(context_start, context, sizeof(context_start));
+
+    availability = intra_availability(s->recon, 0, x, y, 8,
+                                      region_x, region_y, region_size);
+    unit_availability = availability;
+    prediction = predicted_mode(context, x, y);
+    unit8.predicted_mode[0] = prediction;
+    cost8 = search_predicted_block(
+        s, src, x, y, 8, availability, prediction, table, bound,
+        &unit8.residual.block[0], &unit8.mode[0],
+        &unit8.plane_delta[0]);
+    unit8.residual.mask = unit8.residual.block[0].has_coeffs;
+    unit8.residual.transform8 = unit8.residual.mask;
+    commit_predicted_mode(context, x, y, 8, unit8.mode[0]);
+    memcpy(context8, context, sizeof(context8));
+    copy_frame_block(s->recon, 0, x, y, 8, pixels8);
+
+    put_frame_block(s->recon, 0, x, y, 8, original);
+    memcpy(context, context_start, sizeof(context_start));
+    split_bound = FFMIN(bound, cost8);
+    unit4.split4 = 1;
+    for (int by = 0, n = 0; by < 8 && split_valid; by += 4) {
+        for (int bx = 0; bx < 8; bx += 4, n++) {
+            int px = x + bx;
+            int py = y + by;
+            int child_cost;
+
+            /*
+             * Propagate the enclosing 8x8 availability bits into its 4x4
+             * children.  The bottom-right child loses top-right availability
+             * because that neighbour has not yet been reconstructed.
+             */
+            switch (n) {
+            case 0:
+                availability = unit_availability | 4;
+                break;
+            case 1:
+                availability = unit_availability | 2;
+                break;
+            case 2:
+                availability = unit_availability | 5;
+                break;
+            case 3:
+                availability = (unit_availability & ~4) | 3;
+                break;
+            }
+            prediction = predicted_mode(context, px, py);
+            unit4.predicted_mode[n] = prediction;
+            child_cost = search_predicted_block(
+                s, src, px, py, 4, availability, prediction, table,
+                split_bound - cost4,
+                &unit4.residual.block[n], &unit4.mode[n],
+                &unit4.plane_delta[n]);
+            if (child_cost == MOBI_COST_INF) {
+                split_valid = 0;
+                break;
+            }
+            cost4 += child_cost;
+            if (unit4.residual.block[n].has_coeffs)
+                unit4.residual.mask |= 1U << n;
+            commit_predicted_mode(context, px, py, 4, unit4.mode[n]);
+        }
+    }
+    if (split_valid) {
+        memcpy(context4, context, sizeof(context4));
+        copy_frame_block(s->recon, 0, x, y, 8, pixels4);
+    }
+    if (split_valid) {
+        int index = find_mask(ff_mobiclip_i4_masks,
+                              FF_ARRAY_ELEMS(ff_mobiclip_i4_masks),
+                              unit4.residual.mask);
+        int syntax_cost;
+
+        av_assert2(index >= 0);
+        syntax_cost = mobi_ue_bits(index + 1) * lambda;
+        if (syntax_cost >= split_bound - cost4)
+            split_valid = 0;
+        else
+            cost4 += syntax_cost;
+    }
+
+    if (split_valid && cost4 < cost8) {
+        *result = unit4;
+        memcpy(context, context4, sizeof(context4));
+        put_frame_block(s->recon, 0, x, y, 8, pixels4);
+        return cost4;
+    }
+
+    /*
+     * Accept a direct predicted unit whose cost equals the caller's bound.
+     * Only a result strictly above the bound means no candidate.  Preserving
+     * equality can leave the following common-luma pass able to win.
+     */
+    if (cost8 > bound) {
+        memcpy(context, context_start, sizeof(context_start));
+        put_frame_block(s->recon, 0, x, y, 8, original);
+        return MOBI_COST_INF;
+    }
+    *result = unit8;
+    memcpy(context, context8, sizeof(context8));
+    put_frame_block(s->recon, 0, x, y, 8, pixels8);
+    return cost8;
+}
+
+static unsigned intra_component_mask(const MobiIntraMacroblock *mb)
+{
+    unsigned mask = 0;
+
+    for (int i = 0; i < 4; i++)
+        if (mb->luma[i].residual.mask || mb->luma[i].split4)
+            mask |= 1U << i;
+    for (int i = 0; i < 2; i++)
+        if (mb->chroma[i].residual.mask || mb->chroma[i].split4)
+            mask |= 1U << (i + 4);
+    return mask;
+}
+
+static int evaluate_common_chroma(MobiClipEncContext *s,
+                                  const AVFrame *src, int x, int y,
+                                  int table, int bound,
+                                  MobiIntraMacroblock *result)
+{
+    int plane[2] = { 1 + !s->moflex, 2 - !s->moflex };
+    uint8_t original[2][64], pixels[2][64], best_pixels[2][64];
+    MobiIntraUnit candidate[2], best[2];
+    const uint8_t *modes;
+    int lambda = s->lambda;
+    int best_cost = bound;
+    int found = 0;
+    int cx = x >> 1, cy = y >> 1, count;
+    int availability = intra_availability(s->recon, plane[0], cx, cy, 8,
+                                          cx, cy, 8) & 3;
+
+    for (int p = 0; p < 2; p++)
+        copy_frame_block(s->recon, plane[p], cx, cy, 8, original[p]);
+    modes = intra_candidates(availability, &count);
+    for (int i = 0; i < count; i++) {
+        int cost = 3 * lambda;
+        int mode = modes[i];
+        int delta[2] = { 0, 0 };
+        int valid = cost < best_cost;
+
+        for (int p = 0; p < 2 && valid; p++) {
+            int unit_cost;
+
+            put_frame_block(s->recon, plane[p], cx, cy, 8, original[p]);
+            if (mode == 2)
+                delta[p] = optimize_plane_offset(s, src, plane[p],
+                                                 cx, cy, 8);
+            if (mode == 2) {
+                int plane_cost = mobi_se_bits(delta[p]) * lambda;
+
+                if (plane_cost >= best_cost - cost) {
+                    valid = 0;
+                    break;
+                }
+                cost += plane_cost;
+            }
+            unit_cost = evaluate_fixed_intra_unit(
+                s, src, plane[p], cx, cy, table,
+                mode == 2 ? 9 : mode, best_cost - cost, &candidate[p]);
+            if (unit_cost == MOBI_COST_INF) {
+                valid = 0;
+                break;
+            }
+            cost += unit_cost;
+            copy_frame_block(s->recon, plane[p], cx, cy, 8, pixels[p]);
+        }
+        if (valid && cost < best_cost) {
+            found = 1;
+            best_cost = cost;
+            result->chroma_mode = mode == 2 ? 9 : mode;
+            memcpy(result->chroma_plane_delta, delta, sizeof(delta));
+            memcpy(best, candidate, sizeof(best));
+            memcpy(best_pixels, pixels, sizeof(best_pixels));
+        }
+    }
+    if (!found) {
+        for (int p = 0; p < 2; p++)
+            put_frame_block(s->recon, plane[p], cx, cy, 8, original[p]);
+        return MOBI_COST_INF;
+    }
+    memcpy(result->chroma, best, sizeof(best));
+    memcpy(result->pixels.chroma, best_pixels, sizeof(best_pixels));
+    for (int p = 0; p < 2; p++)
+        put_frame_block(s->recon, plane[p], cx, cy, 8, best_pixels[p]);
+    return best_cost;
+}
+
+static int evaluate_common_luma(MobiClipEncContext *s,
+                                const AVFrame *src, int x, int y,
+                                int picture_is_intra, int bound,
+                                MobiIntraMacroblock *result)
+{
+    MobiIntraUnit candidate[4], best[4];
+    uint8_t original[256], pixels[256], best_pixels[256];
+    const uint8_t *modes;
+    int lambda = s->lambda;
+    int picture_cost = (picture_is_intra ? 1 : 5) * lambda;
+    int best_cost = bound;
+    int found = 0;
+    int count;
+    int availability = intra_availability(s->recon, 0, x, y, 16,
+                                          x, y, 16) & 3;
+
+    copy_frame_block(s->recon, 0, x, y, 16, original);
+    modes = intra_candidates(availability, &count);
+    for (int i = 0; i < count; i++) {
+        int cost = picture_cost + 3 * lambda;
+        int mode = modes[i];
+        int delta = 0;
+        /* Reserve the picture syntax cost again in this outer bound. */
+        int valid = cost < best_cost - picture_cost;
+
+        put_frame_block(s->recon, 0, x, y, 16, original);
+        if (mode == 2)
+            delta = optimize_plane_offset(s, src, 0, x, y, 16);
+        if (mode == 2) {
+            int plane_cost = mobi_se_bits(delta) * lambda;
+
+            if (plane_cost >= best_cost - cost)
+                valid = 0;
+            else
+                cost += plane_cost;
+        }
+        for (int by = 0, n = 0; by < 16 && valid; by += 8) {
+            for (int bx = 0; bx < 16; bx += 8, n++) {
+                int unit_cost = evaluate_fixed_intra_unit(
+                    s, src, 0, x + bx, y + by, picture_is_intra,
+                    mode == 2 ? 9 : mode, best_cost - cost,
+                    &candidate[n]);
+
+                if (unit_cost == MOBI_COST_INF) {
+                    valid = 0;
+                    break;
+                }
+                cost += unit_cost;
+            }
+        }
+        if (valid)
+            copy_frame_block(s->recon, 0, x, y, 16, pixels);
+        if (valid && cost < best_cost) {
+            found = 1;
+            best_cost = cost;
+            result->luma_mode = mode == 2 ? 9 : mode;
+            result->luma_plane_delta = delta;
+            memcpy(best, candidate, sizeof(best));
+            memcpy(best_pixels, pixels, sizeof(best_pixels));
+        }
+    }
+    if (!found) {
+        put_frame_block(s->recon, 0, x, y, 16, original);
+        return MOBI_COST_INF;
+    }
+    memcpy(result->luma, best, sizeof(best));
+    memcpy(result->pixels.luma, best_pixels, sizeof(best_pixels));
+    put_frame_block(s->recon, 0, x, y, 16, best_pixels);
+    return best_cost;
+}
+
+static int evaluate_predicted_luma(MobiClipEncContext *s,
+                                   const AVFrame *src, int x, int y,
+                                   int picture_is_intra, int bound,
+                                   uint8_t context[20],
+                                   MobiIntraMacroblock *result)
+{
+    int cost = (picture_is_intra ? 1 : 5) * s->lambda;
+
+    /* The outer gate reserves its picture syntax cost a second time. */
+    if (cost >= bound - cost)
+        return MOBI_COST_INF;
+    for (int by = 0, n = 0; by < 16; by += 8) {
+        for (int bx = 0; bx < 16; bx += 8, n++) {
+            int unit_cost = evaluate_predicted_intra_unit(
+                s, src, x + bx, y + by, x, y, 16, context,
+                picture_is_intra, bound - cost, &result->luma[n]);
+
+            if (unit_cost == MOBI_COST_INF)
+                return MOBI_COST_INF;
+            cost += unit_cost;
+        }
+    }
+    copy_frame_block(s->recon, 0, x, y, 16, result->pixels.luma);
+    return cost;
+}
+
+static void commit_intra_macroblock(MobiClipEncContext *s, int x, int y,
+                                    const MobiIntraMacroblock *mb)
+{
+    int plane[2] = { 1 + !s->moflex, 2 - !s->moflex };
+
+    put_frame_block(s->recon, 0, x, y, 16, mb->pixels.luma);
+    for (int p = 0; p < 2; p++)
+        put_frame_block(s->recon, plane[p], x >> 1, y >> 1, 8,
+                        mb->pixels.chroma[p]);
+}
+
+static int evaluate_intra_macroblock(MobiClipEncContext *s,
+                                     const AVFrame *src, int x, int y,
+                                     int picture_is_intra, int old_cost,
+                                     MobiIntraMacroblock *result)
+{
+    MobiIntraMacroblock chroma = { 0 }, common = { 0 }, predicted = { 0 };
+    uint8_t predicted_context[20];
+    int chroma_cost, common_luma_cost, predicted_luma_cost;
+    int common_cost = MOBI_COST_INF;
+    int predicted_cost;
+    int remaining;
+    unsigned mask;
+    int index;
+
+    chroma_cost = evaluate_common_chroma(s, src, x, y,
+                                         picture_is_intra, old_cost, &chroma);
+    if (chroma_cost == MOBI_COST_INF)
+        return 0;
+    remaining = old_cost - chroma_cost;
+    memcpy(predicted_context, s->intra_context, sizeof(predicted_context));
+
+    predicted = chroma;
+    predicted.predicted = 1;
+    predicted_luma_cost = evaluate_predicted_luma(
+        s, src, x, y, picture_is_intra, remaining,
+        predicted_context, &predicted);
+    if (predicted_luma_cost == MOBI_COST_INF)
+        return 0;
+    mask = intra_component_mask(&predicted);
+    index = find_mask(ff_mobiclip_i8_masks,
+                      FF_ARRAY_ELEMS(ff_mobiclip_i8_masks), mask);
+    av_assert2(index >= 0);
+    predicted_cost = chroma_cost + predicted_luma_cost +
+                     mobi_ue_bits(index) * s->lambda;
+    predicted.cost = predicted_cost;
+    predicted.component_mask = mask;
+
+    common = chroma;
+    common_luma_cost = evaluate_common_luma(
+        s, src, x, y, picture_is_intra, predicted_luma_cost, &common);
+    if (common_luma_cost != MOBI_COST_INF) {
+        mask = intra_component_mask(&common);
+        index = find_mask(ff_mobiclip_i8_masks,
+                          FF_ARRAY_ELEMS(ff_mobiclip_i8_masks), mask);
+        av_assert2(index >= 0);
+        common_cost = chroma_cost + common_luma_cost +
+                      mobi_ue_bits(index) * s->lambda;
+        common.cost = common_cost;
+        common.component_mask = mask;
+    }
+    /*
+     * Keep common mode on a predicted/common tie and retain an already
+     * selected inter mode on an intra/inter tie.
+     */
+    if (predicted_cost < FFMIN(old_cost, common_cost)) {
+        *result = predicted;
+        memcpy(s->intra_context, predicted_context,
+               sizeof(s->intra_context));
+    } else if (common_cost < old_cost) {
+        *result = common;
+    } else {
+        return 0;
+    }
+    commit_intra_macroblock(s, x, y, result);
+    return 1;
+}
+
+static void put_coefficients(const MobiClipEncContext *s, PutBitContext *pb,
+                             const int16_t *level, int count, int table)
+{
+    int last_pos = -1;
+
+    for (int i = count - 1; i >= 0; i--)
+        if (level[i]) {
+            last_pos = i;
+            break;
+        }
+
+    av_assert2(last_pos >= 0);
+    for (int pos = 0, previous = -1; pos <= last_pos; pos++) {
+        int run;
+
+        if (!level[pos])
+            continue;
+        run = pos - previous - 1;
+        previous = pos;
+
+        {
+            int last = pos == last_pos;
+            int magnitude = FFABS(level[pos]);
+            unsigned symbol = (last << 11) | (run << 5) | magnitude;
+            int best_mode = 3;
+            unsigned best_symbol = 0;
+
+            /*
+             * The initialized lookup table prefers syntax classes in this
+             * order; a shorter run escape does not supersede a valid level
+             * escape.
+             */
+            if (magnitude <= 31 && s->rl_len[table][symbol]) {
+                best_mode = 0;
+                best_symbol = symbol;
+            }
+
+            if (best_mode == 3) {
+                int base_level = magnitude -
+                                 s->rl_level_residue[table][last][run];
+
+                if (base_level >= 0 && base_level <= 31) {
+                    unsigned base_symbol = (last << 11) | (run << 5) |
+                                           base_level;
+
+                    if (s->rl_len[table][base_symbol]) {
+                        best_mode = 1;
+                        best_symbol = base_symbol;
+                    }
+                }
+            }
+
+            if (best_mode == 3 && magnitude <= 31) {
+                int base_run = run -
+                               s->rl_run_residue[table][last][magnitude];
+
+                if (base_run >= 0 && base_run <= 63) {
+                    unsigned base_symbol = (last << 11) |
+                                           (base_run << 5) | magnitude;
+
+                    if (s->rl_len[table][base_symbol]) {
+                        best_mode = 2;
+                        best_symbol = base_symbol;
+                    }
+                }
+            }
+
+            if (!best_mode) {
+                put_bits(pb, s->rl_len[table][best_symbol],
+                         s->rl_code[table][best_symbol]);
+                put_bits(pb, 1, level[pos] < 0);
+            } else if (best_mode == 1) {
+                put_bits(pb, s->rl_len[table][0], s->rl_code[table][0]);
+                put_bits(pb, 1, 0);
+                put_bits(pb, s->rl_len[table][best_symbol],
+                         s->rl_code[table][best_symbol]);
+                put_bits(pb, 1, level[pos] < 0);
+            } else if (best_mode == 2) {
+                put_bits(pb, s->rl_len[table][0], s->rl_code[table][0]);
+                put_bits(pb, 2, 2);
+                put_bits(pb, s->rl_len[table][best_symbol],
+                         s->rl_code[table][best_symbol]);
+                put_bits(pb, 1, level[pos] < 0);
+            } else {
+                put_bits(pb, s->rl_len[table][0], s->rl_code[table][0]);
+                put_bits(pb, 2, 3);
+                put_bits(pb, 1, last);
+                put_bits(pb, 6, run);
+                put_sbits(pb, 12, level[pos]);
+            }
+        }
+    }
+}
+
+static void put_intra_unit(const MobiClipEncContext *s, PutBitContext *pb,
+                           const MobiEncUnit *unit, int table)
+{
+    if (unit->transform8) {
+        av_assert2(unit->mask);
+        set_ue_golomb(pb, 0);
+        put_coefficients(s, pb, unit->block[0].level, 64, table);
+    } else {
+        int index = find_mask(ff_mobiclip_i4_masks,
+                              FF_ARRAY_ELEMS(ff_mobiclip_i4_masks),
+                              unit->mask);
+
+        av_assert2(index >= 0);
+        set_ue_golomb(pb, index + 1);
+        for (int i = 0; i < 4; i++)
+            if (unit->mask & (1U << i))
+                put_coefficients(s, pb, unit->block[i].level, 16, table);
+    }
+}
+
+static void put_predicted_mode(PutBitContext *pb, int mode, int prediction)
+{
+    if (mode == prediction) {
+        put_bits(pb, 1, 1);
+    } else {
+        put_bits(pb, 1, 0);
+        put_bits(pb, 3, mode - (mode > prediction));
+    }
+}
+
+static void put_predicted_intra_unit(const MobiClipEncContext *s,
+                                     PutBitContext *pb,
+                                     const MobiIntraUnit *unit, int table)
+{
+    if (!unit->split4) {
+        if (unit->residual.mask)
+            set_ue_golomb(pb, 0);
+        put_predicted_mode(pb, unit->mode[0], unit->predicted_mode[0]);
+        if (unit->mode[0] == 2)
+            set_se_golomb(pb, unit->plane_delta[0]);
+        if (unit->residual.mask)
+            put_coefficients(s, pb, unit->residual.block[0].level, 64, table);
+    } else {
+        int index = find_mask(ff_mobiclip_i4_masks,
+                              FF_ARRAY_ELEMS(ff_mobiclip_i4_masks),
+                              unit->residual.mask);
+
+        av_assert2(index >= 0);
+        set_ue_golomb(pb, index + 1);
+        for (int i = 0; i < 4; i++) {
+            put_predicted_mode(pb, unit->mode[i],
+                               unit->predicted_mode[i]);
+            if (unit->mode[i] == 2)
+                set_se_golomb(pb, unit->plane_delta[i]);
+            if (unit->residual.mask & (1U << i))
+                put_coefficients(s, pb, unit->residual.block[i].level,
+                                 16, table);
+        }
+    }
+}
+
+static void setup_qtable(MobiClipEncContext *s)
+{
+    for (int i = 0; i < 16; i++) {
+        s->qtab[i] = ff_mobiclip_quant4x4[s->qrem][i] << s->qdiv;
+        s->qmul4[i] =
+            mobi_mf4[s->qrem][mobi_4x4_category[i]];
+    }
+    for (int i = 0; i < 64; i++) {
+        s->qmul8[i] =
+            mobi_mf8[s->qrem][mobi_8x8_category[i]];
+        s->dequant8[i] =
+            mobi_dequant8[s->qrem][mobi_8x8_category[i]];
+    }
+}
+
+static void setup_rl_codes(MobiClipEncContext *s)
+{
+    for (int table = 0; table < 2; table++) {
+        uint64_t code = 0;
+
+        /*
+         * Escape mode 2 adds one to the represented run even when a
+         * magnitude has no direct VLC entry.  Escape mode 1, in contrast,
+         * adds the largest directly represented magnitude itself.
+         */
+        memset(s->rl_run_residue[table], 1,
+               sizeof(s->rl_run_residue[table]));
+        for (int i = 0; i < MOBICLIP_RL_ENTRIES; i++) {
+            int len = ff_mobiclip_rl_bits[i];
+            unsigned symbol = ff_mobiclip_rl_syms[table][i];
+
+            s->rl_code[table][symbol] = code >> (32 - len);
+            s->rl_len[table][symbol] = len;
+            s->rl_level_residue[table][symbol >> 11]
+                               [(symbol >> 5) & 0x3F] =
+                FFMAX(s->rl_level_residue[table][symbol >> 11]
+                                          [(symbol >> 5) & 0x3F],
+                      symbol & 0x1F);
+            s->rl_run_residue[table][symbol >> 11][symbol & 0x1F] =
+                FFMAX(s->rl_run_residue[table][symbol >> 11]
+                                        [symbol & 0x1F],
+                      ((symbol >> 5) & 0x3F) + 1);
+            code += UINT64_C(1) << (32 - len);
+        }
+        for (int last = 0; last < 2; last++) {
+            for (int run = 0; run < 64; run++) {
+                for (int magnitude = 0;
+                     magnitude < MOBI_RL_COST_FULL_ESCAPE;
+                     magnitude++)
+                    s->rl_cost[table][last][run][magnitude] =
+                        coefficient_symbol_bits(s, table, last, run,
+                                                magnitude);
+                /*
+                 * Escape modes 1 and 2 cannot represent magnitudes above
+                 * 62, so every larger magnitude has the full-escape cost.
+                 */
+                s->rl_cost[table][last][run]
+                          [MOBI_RL_COST_FULL_ESCAPE] =
+                    coefficient_symbol_bits(
+                        s, table, last, run,
+                        MOBI_RL_COST_FULL_ESCAPE);
+            }
+        }
+    }
+}
+
+static void put_intra_macroblock_payload(const MobiClipEncContext *s,
+                                         PutBitContext *pb,
+                                         const MobiIntraMacroblock *mb,
+                                         int table)
+{
+    set_ue_golomb(pb, find_mask(ff_mobiclip_i8_masks,
+                                FF_ARRAY_ELEMS(ff_mobiclip_i8_masks),
+                                mb->component_mask));
+    if (mb->predicted) {
+        for (int i = 0; i < 4; i++)
+            put_predicted_intra_unit(s, pb, &mb->luma[i], table);
+    } else {
+        put_bits(pb, 3, mb->luma_mode == 9 ? 2 : mb->luma_mode);
+        if (mb->luma_mode == 9)
+            set_se_golomb(pb, mb->luma_plane_delta);
+        for (int i = 0; i < 4; i++)
+            if (mb->component_mask & (1U << i))
+                put_intra_unit(s, pb, &mb->luma[i].residual, table);
+    }
+    put_bits(pb, 3, mb->chroma_mode == 9 ? 2 : mb->chroma_mode);
+    if (mb->chroma_mode == 9) {
+        set_se_golomb(pb, mb->chroma_plane_delta[0]);
+        set_se_golomb(pb, mb->chroma_plane_delta[1]);
+    }
+    for (int i = 0; i < 2; i++)
+        if (mb->component_mask & (1U << (i + 4)))
+            put_intra_unit(s, pb, &mb->chroma[i].residual, table);
+}
+
+static int encode_intra(AVCodecContext *avctx, PutBitContext *pb,
+                        const AVFrame *src)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+
+    put_bits(pb, 1, 1);
+    put_bits(pb, 1, s->moflex);
+    put_bits(pb, 1, 1); /* Select the encoder's intra run/level VLC table. */
+    put_bits(pb, MOBI_QP_BITS, s->qp);
+    memset(s->intra_context, 9, sizeof(s->intra_context));
+
+    for (int y = 0; y < avctx->height; y += 16) {
+        for (int x = 0; x < avctx->width; x += 16) {
+            MobiIntraMacroblock mb;
+            int installed;
+
+            installed = evaluate_intra_macroblock(s, src, x, y, 1,
+                                                   MOBI_COST_INF, &mb);
+            av_assert2(installed);
+            if (!installed)
+                return AVERROR_BUG;
+            put_bits(pb, 1, mb.predicted);
+            put_intra_macroblock_payload(s, pb, &mb, 1);
+        }
+    }
+    return 0;
+}
+
+static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+    int ret;
+
+    ff_mobiclipencdsp_init(&s->dsp);
+    ff_mobiclipenc_intra_pred_init(&s->intra_pred);
+
+    if (avctx->width & 15 || avctx->height & 15) {
+        av_log(avctx, AV_LOG_ERROR,
+               "MobiClip width and height must be multiples of 16\n");
+        return AVERROR(EINVAL);
+    }
+    if (s->qp < MOBI_MIN_QP || s->qp > MOBI_MAX_QP) {
+        av_log(avctx, AV_LOG_ERROR,
+               "MobiClip qp must be in the range 12..63\n");
+        return AVERROR(EINVAL);
+    }
+    if (avctx->profile != AV_PROFILE_MOBICLIP_MOC5 &&
+        avctx->profile != AV_PROFILE_MOBICLIP_MOFLEX) {
+        av_log(avctx, AV_LOG_ERROR,
+               "MobiClip profile must be MOC5 or Moflex\n");
+        return AVERROR(EINVAL);
+    }
+    s->moflex = avctx->profile == AV_PROFILE_MOBICLIP_MOFLEX;
+    if ((ret = av_image_check_size(avctx->width, avctx->height, 0, avctx)) < 0)
+        return ret;
+
+    s->recon = av_frame_alloc();
+    if (!s->recon)
+        return AVERROR(ENOMEM);
+    s->recon->format = avctx->pix_fmt;
+    s->recon->width  = avctx->width;
+    s->recon->height = avctx->height;
+    if ((ret = av_frame_get_buffer(s->recon, 32)) < 0)
+        return ret;
+
+    mobi_set_qp(s, s->qp);
+    setup_qtable(s);
+    setup_rl_codes(s);
+    s->input = av_frame_alloc();
+    if (!s->input)
+        return AVERROR(ENOMEM);
+    avctx->bits_per_raw_sample = 8;
+    return 0;
+}
+
+static av_cold int mobiclip_encode_close(AVCodecContext *avctx)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+
+    av_frame_free(&s->recon);
+    av_frame_free(&s->input);
+    return 0;
+}
+
+static int mobiclip_encode_frame(AVCodecContext *avctx, AVPacket *pkt,
+                                 const AVFrame *frame, int *got_packet)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+    int64_t samples = avctx->width * (int64_t)avctx->height * 3 / 2;
+    int64_t max_size = samples * 4 +
+                       avctx->width * (int64_t)avctx->height / 16 + 64;
+    PutBitContext pb;
+    int ret, size;
+
+    if (!frame)
+        return 0;
+    if (max_size > INT_MAX)
+        return AVERROR(ERANGE);
+    if ((ret = ff_get_encode_buffer(avctx, pkt, max_size, 0)) < 0)
+        return ret;
+    if ((ret = av_frame_make_writable(s->recon)) < 0)
+        return ret;
+
+    init_put_bits(&pb, pkt->data, max_size);
+    if ((ret = encode_intra(avctx, &pb, frame)) < 0)
+        return ret;
+    flush_put_bits(&pb);
+    size = put_bytes_output(&pb);
+
+    /* Packets are stored with the two bytes of every 16-bit word swapped. */
+    if (size & 1)
+        pkt->data[size++] = 0;
+    for (int i = 0; i < size; i += 2)
+        FFSWAP(uint8_t, pkt->data[i], pkt->data[i + 1]);
+    pkt->size = size;
+    pkt->flags |= AV_PKT_FLAG_KEY;
+    pkt->duration = 1;
+    *got_packet = 1;
+    return 0;
+}
+
+static int mobiclip_receive_packet(AVCodecContext *avctx, AVPacket *pkt)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+    int got_packet = 0;
+    int ret;
+
+    ret = ff_encode_get_frame(avctx, s->input);
+    if (ret < 0)
+        return ret;
+    ret = mobiclip_encode_frame(avctx, pkt, s->input, &got_packet);
+    if (ret < 0)
+        goto end;
+    if (!got_packet) {
+        ret = AVERROR(EAGAIN);
+        goto end;
+    }
+
+    pkt->pts = pkt->dts = s->input->pts;
+    if (s->input->duration)
+        pkt->duration = s->input->duration;
+    if (avctx->flags & AV_CODEC_FLAG_COPY_OPAQUE) {
+        pkt->opaque = s->input->opaque;
+        ret = av_buffer_replace(&pkt->opaque_ref, s->input->opaque_ref);
+        if (ret < 0)
+            goto end;
+    }
+    ret = ff_encode_reordered_opaque(avctx, pkt, s->input);
+
+end:
+    av_frame_unref(s->input);
+    if (ret < 0)
+        av_packet_unref(pkt);
+    return ret;
+}
+
+#define OFFSET(x) offsetof(MobiClipEncContext, x)
+#define VE (AV_OPT_FLAG_VIDEO_PARAM | AV_OPT_FLAG_ENCODING_PARAM)
+static const AVOption mobiclip_options[] = {
+    FF_AVCTX_PROFILE_OPTION("moc5", "use the Wii MO bitstream profile",
+                            VIDEO, AV_PROFILE_MOBICLIP_MOC5)
+    FF_AVCTX_PROFILE_OPTION("moflex", "use the flexible bitstream profile",
+                            VIDEO, AV_PROFILE_MOBICLIP_MOFLEX)
+    { "qp", "set the direct MobiClip bitstream quantizer",
+      OFFSET(qp), AV_OPT_TYPE_INT, { .i64 = MOBI_DEFAULT_QP },
+      MOBI_MIN_QP, MOBI_MAX_QP, VE },
+    { NULL },
+};
+
+static const AVClass mobiclip_encoder_class = {
+    .class_name = "MobiClip encoder",
+    .item_name  = av_default_item_name,
+    .option     = mobiclip_options,
+    .version    = LIBAVUTIL_VERSION_INT,
+};
+
+static const FFCodecDefault mobiclip_defaults[] = {
+    { "profile", "moflex" },
+    { NULL },
+};
+
+const FFCodec ff_mobiclip_encoder = {
+    .p.name         = "mobiclip",
+    CODEC_LONG_NAME("MobiClip Video"),
+    .p.type         = AVMEDIA_TYPE_VIDEO,
+    .p.id           = AV_CODEC_ID_MOBICLIP,
+    .p.capabilities = AV_CODEC_CAP_DR1 |
+                      AV_CODEC_CAP_ENCODER_REORDERED_OPAQUE,
+    .priv_data_size = sizeof(MobiClipEncContext),
+    .init           = mobiclip_encode_init,
+    FF_CODEC_RECEIVE_PACKET_CB(mobiclip_receive_packet),
+    .close          = mobiclip_encode_close,
+    .defaults       = mobiclip_defaults,
+    CODEC_PIXFMTS(AV_PIX_FMT_YUV420P),
+    .color_ranges   = AVCOL_RANGE_MPEG,
+    .p.profiles     = NULL_IF_CONFIG_SMALL(ff_mobiclip_profiles),
+    .p.priv_class   = &mobiclip_encoder_class,
+    .caps_internal  = FF_CODEC_CAP_INIT_CLEANUP,
+};
diff --git a/libavcodec/mobiclipencdsp.c b/libavcodec/mobiclipencdsp.c
new file mode 100644
index 0000000000..9e3d935112
--- /dev/null
+++ b/libavcodec/mobiclipencdsp.c
@@ -0,0 +1,286 @@
+/*
+ * MobiClip encoder DSP
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "config.h"
+
+#include "libavutil/attributes.h"
+#include "libavutil/common.h"
+
+#include "mobiclipdata.h"
+#include "mobiclipencdsp.h"
+
+static void fdct4_1d(const int16_t x[4], int16_t y[4])
+{
+    int s03 = x[0] + x[3];
+    int s12 = x[1] + x[2];
+    int d03 = x[0] - x[3];
+    int d12 = x[1] - x[2];
+
+    y[0] = s03 + s12;
+    y[1] = 2 * d03 + d12;
+    y[2] = s03 - s12;
+    y[3] = d03 - 2 * d12;
+}
+
+static int residual_fdct4_c(int16_t residual[16], int16_t coeff[16],
+                            const uint8_t *src, ptrdiff_t src_stride,
+                            const uint8_t *prediction,
+                            ptrdiff_t prediction_stride)
+{
+    int16_t tmp[4][4];
+    int raw_sse = 0;
+
+    for (int y = 0; y < 4; y++) {
+        int16_t out[4];
+
+        for (int x = 0; x < 4; x++) {
+            int delta = src[x] - prediction[x];
+
+            residual[4 * y + x] = delta;
+            raw_sse += delta * delta;
+        }
+        fdct4_1d(residual + 4 * y, out);
+        for (int x = 0; x < 4; x++)
+            tmp[x][y] = out[x];
+        src        += src_stride;
+        prediction += prediction_stride;
+    }
+    for (int y = 0; y < 4; y++)
+        fdct4_1d(tmp[y], coeff + 4 * y);
+
+    return raw_sse;
+}
+
+static int reconstruct4x4_c(uint8_t *dst, ptrdiff_t dst_stride,
+                            const int16_t level[16], const int qtab[16],
+                            const int16_t residual[16])
+{
+    int mat[16] = { 0 };
+    uint32_t sse = 0;
+
+    for (int i = 0; i < 16; i++)
+        mat[ff_mobiclip_zigzag4x4[i]] = level[i] * qtab[i];
+
+    mat[0] += 32;
+    for (int y = 0; y < 4; y++)
+        ff_mobiclip_inverse4(mat + 4 * y);
+
+    for (int y = 0; y < 4; y++)
+        for (int x = y + 1; x < 4; x++)
+            FFSWAP(int, mat[4 * y + x], mat[4 * x + y]);
+
+    for (int y = 0; y < 4; y++) {
+        ff_mobiclip_inverse4(mat + 4 * y);
+        for (int x = 0; x < 4; x++) {
+            int16_t reconstructed = av_clip_int16(mat[4 * y + x] >> 6);
+            int delta = av_clip_int16(residual[4 * y + x] - reconstructed);
+
+            sse += delta * delta;
+            dst[x] = av_clip_uint8(dst[x] + reconstructed);
+        }
+        dst += dst_stride;
+    }
+
+    return sse;
+}
+
+static void fdct8_1d(const int16_t x[8], int16_t y[8])
+{
+    int s07 = x[0] + x[7];
+    int s16 = x[1] + x[6];
+    int s25 = x[2] + x[5];
+    int s34 = x[3] + x[4];
+    int a0 = s07 + s34;
+    int a1 = s16 + s25;
+    int a2 = s07 - s34;
+    int a3 = s16 - s25;
+    int d07 = x[0] - x[7];
+    int d16 = x[1] - x[6];
+    int d25 = x[2] - x[5];
+    int d34 = x[3] - x[4];
+    int a4 = d16 + d25 + d07 + (d07 >> 1);
+    int a5 = d07 - d34 - d25 - (d25 >> 1);
+    int a6 = d07 + d34 - d16 - (d16 >> 1);
+    int a7 = d16 - d25 + d34 + (d34 >> 1);
+
+    y[0] = a0 + a1;
+    y[1] = a4 + (a7 >> 2);
+    y[2] = a2 + (a3 >> 1);
+    y[3] = a5 + (a6 >> 2);
+    y[4] = a0 - a1;
+    y[5] = a6 - (a5 >> 2);
+    y[6] = (a2 >> 1) - a3;
+    y[7] = (a4 >> 2) - a7;
+}
+
+static int residual_fdct8_c(int16_t residual[64], int16_t coeff[64],
+                            const uint8_t *src, ptrdiff_t src_stride,
+                            const uint8_t *prediction,
+                            ptrdiff_t prediction_stride)
+{
+    int16_t tmp[8][8];
+    int raw_sse = 0;
+
+    for (int y = 0; y < 8; y++) {
+        for (int x = 0; x < 8; x++) {
+            int delta = src[x] - prediction[x];
+
+            residual[8 * y + x] = delta;
+            raw_sse += delta * delta;
+        }
+        src        += src_stride;
+        prediction += prediction_stride;
+    }
+    for (int x = 0; x < 8; x++) {
+        int16_t in[8], out[8];
+
+        for (int y = 0; y < 8; y++)
+            in[y] = residual[8 * y + x];
+        fdct8_1d(in, out);
+        for (int y = 0; y < 8; y++)
+            tmp[y][x] = out[y];
+    }
+    for (int y = 0; y < 8; y++) {
+        int16_t out[8];
+
+        fdct8_1d(tmp[y], out);
+        for (int x = 0; x < 8; x++)
+            coeff[8 * x + y] = out[x];
+    }
+
+    return raw_sse;
+}
+
+static void inverse8_1d(int16_t *v)
+{
+    int16_t a = v[0] + v[4];
+    int16_t b = v[0] - v[4];
+    int16_t c = v[2] + (v[6] >> 1);
+    int16_t d = (v[2] >> 1) - v[6];
+    int16_t tmp[4] = { a + c, b + d, b - d, a - c };
+    int16_t e = v[7] + v[1] - v[3] - (v[3] >> 1);
+    int16_t f = v[7] - v[1] + v[5] + (v[5] >> 1);
+    int16_t g = v[5] - v[3] - v[7] - (v[7] >> 1);
+    int16_t h = v[5] + v[3] + v[1] + (v[1] >> 1);
+    int16_t x3 = g + (h >> 2);
+    int16_t x2 = e + (f >> 2);
+    int16_t x1 = (e >> 2) - f;
+    int16_t x0 = h - (g >> 2);
+
+    v[0] = tmp[0] + x0;
+    v[1] = tmp[1] + x1;
+    v[2] = tmp[2] + x2;
+    v[3] = tmp[3] + x3;
+    v[4] = tmp[3] - x3;
+    v[5] = tmp[2] - x2;
+    v[6] = tmp[1] - x1;
+    v[7] = tmp[0] - x0;
+}
+
+static int reconstruct8x8_c(uint8_t *dst, ptrdiff_t dst_stride,
+                            const int16_t level[64],
+                            const int16_t dequant[64], int shift,
+                            const int16_t residual[64])
+{
+    int16_t mat[64];
+    uint32_t sse = 0;
+
+    for (int i = 0; i < 64; i++) {
+        uint16_t product = level[i] * dequant[i];
+
+        mat[i] = product << shift;
+    }
+
+    mat[0] = (uint16_t)(mat[0] + 32);
+    for (int y = 0; y < 8; y++)
+        inverse8_1d(mat + 8 * y);
+
+    for (int y = 0; y < 8; y++)
+        for (int x = y + 1; x < 8; x++)
+            FFSWAP(int16_t, mat[8 * y + x], mat[8 * x + y]);
+
+    for (int y = 0; y < 8; y++) {
+        inverse8_1d(mat + 8 * y);
+        for (int x = 0; x < 8; x++) {
+            int16_t reconstructed = mat[8 * y + x] >> 6;
+            int delta = av_clip_int16(residual[8 * y + x] - reconstructed);
+
+            sse += delta * delta;
+            dst[x] = av_clip_uint8(dst[x] + reconstructed);
+        }
+        dst += dst_stride;
+    }
+
+    return sse;
+}
+
+static int quant4_c(int16_t level[16], const int16_t coeff[16],
+                    const int16_t multiplier[16], int shift, int bias)
+{
+    static const uint8_t zigzag[16] = {
+        0, 4, 1, 2, 5, 8, 12, 9, 6, 3, 7, 10, 13, 14, 11, 15,
+    };
+    int16_t natural_level[16];
+    int has_coeffs = 0;
+
+    for (int i = 0; i < 16; i++) {
+        unsigned magnitude = FFABS(coeff[i]);
+        int16_t scaled = (uint16_t)(magnitude << 3);
+        uint16_t value = av_clip_int16(
+            ((int32_t)scaled * multiplier[i] + (1 << 14)) >> 15);
+        int quantized;
+
+        value += bias;
+        quantized = value >> shift;
+        natural_level[i] = coeff[i] < 0 ? -quantized : quantized;
+    }
+    for (int i = 0; i < 16; i++) {
+        level[i] = natural_level[zigzag[i]];
+        has_coeffs |= !!level[i];
+    }
+
+    return has_coeffs;
+}
+
+static void quant8_c(int16_t level[64], const int16_t coeff[64],
+                     const int16_t multiplier[64], int shift, int bias)
+{
+    for (int i = 0; i < 64; i++) {
+        int doubled = av_clip_int16(2 * FFABS(coeff[i]));
+        uint16_t value = av_clip_int16(
+            ((int32_t)doubled * multiplier[i] + (1 << 14)) >> 15);
+        int quantized;
+
+        value += bias;
+        quantized = value >> shift;
+        level[i] = coeff[i] < 0 ? -quantized : quantized;
+    }
+}
+
+av_cold void ff_mobiclipencdsp_init(MobiClipEncDSPContext *c)
+{
+    c->residual_fdct4 = residual_fdct4_c;
+    c->reconstruct4x4 = reconstruct4x4_c;
+    c->residual_fdct8 = residual_fdct8_c;
+    c->reconstruct8x8 = reconstruct8x8_c;
+    c->quant4         = quant4_c;
+    c->quant8         = quant8_c;
+}
diff --git a/libavcodec/mobiclipencdsp.h b/libavcodec/mobiclipencdsp.h
new file mode 100644
index 0000000000..99a7d3b55c
--- /dev/null
+++ b/libavcodec/mobiclipencdsp.h
@@ -0,0 +1,54 @@
+/*
+ * MobiClip encoder DSP
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#ifndef AVCODEC_MOBICLIPENCDSP_H
+#define AVCODEC_MOBICLIPENCDSP_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "libavutil/attributes.h"
+
+typedef struct MobiClipEncDSPContext {
+    int (*residual_fdct4)(int16_t residual[16], int16_t coeff[16],
+                          const uint8_t *src, ptrdiff_t src_stride,
+                          const uint8_t *prediction,
+                          ptrdiff_t prediction_stride);
+    int (*reconstruct4x4)(uint8_t *dst, ptrdiff_t dst_stride,
+                          const int16_t level[16], const int qtab[16],
+                          const int16_t residual[16]);
+    int (*residual_fdct8)(int16_t residual[64], int16_t coeff[64],
+                          const uint8_t *src, ptrdiff_t src_stride,
+                          const uint8_t *prediction,
+                          ptrdiff_t prediction_stride);
+    int (*reconstruct8x8)(uint8_t *dst, ptrdiff_t dst_stride,
+                          const int16_t level[64],
+                          const int16_t dequant[64], int shift,
+                          const int16_t residual[64]);
+    int (*quant4)(int16_t level[16], const int16_t coeff[16],
+                  const int16_t multiplier[16], int shift, int bias);
+    void (*quant8)(int16_t level[64], const int16_t coeff[64],
+                   const int16_t multiplier[64], int shift, int bias);
+} MobiClipEncDSPContext;
+
+av_cold void ff_mobiclipencdsp_init(MobiClipEncDSPContext *c);
+
+#endif /* AVCODEC_MOBICLIPENCDSP_H */
diff --git a/libavcodec/mobiclipencintrapred.c b/libavcodec/mobiclipencintrapred.c
new file mode 100644
index 0000000000..42b7b8dc8f
--- /dev/null
+++ b/libavcodec/mobiclipencintrapred.c
@@ -0,0 +1,84 @@
+/*
+ * MobiClip encoder intra prediction DSP
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "config.h"
+
+#include <string.h>
+
+#include "libavutil/attributes.h"
+
+#include "mobiclipencintrapred.h"
+
+#define INTRA_PRED_C(size)                                                  \
+static void intra_pred_ ## size ## x ## size ## _c(                         \
+    uint8_t *dst, ptrdiff_t stride, const uint8_t *edge, int mode, int dc)  \
+{                                                                           \
+    for (int y = 0; y < size; y++) {                                        \
+        if (mode == 0)                                                       \
+            memcpy(dst, edge, size);                                         \
+        else if (mode == 1)                                                  \
+            memset(dst, edge[y], size);                                      \
+        else                                                                 \
+            memset(dst, dc, size);                                           \
+        dst += stride;                                                       \
+    }                                                                       \
+}
+
+INTRA_PRED_C(4)
+INTRA_PRED_C(8)
+
+#define INTRA_PLANE_C(size, shift)                                          \
+static void intra_plane_ ## size ## x ## size ## _c(                        \
+    uint8_t *dst, ptrdiff_t stride, const uint8_t *top,                     \
+    const uint8_t *left, int plane_delta)                                   \
+{                                                                           \
+    int a[size], b[size];                                                    \
+    int bottom_left = left[size - 1];                                        \
+    int top_right   = top[size - 1];                                         \
+    int avg = ((bottom_left + top_right + 1) >> 1) + 2 * plane_delta;        \
+    int dx = avg - bottom_left;                                              \
+    int dy = avg - top_right;                                                \
+                                                                            \
+    for (int i = 0; i < size; i++) {                                        \
+        a[i] = (bottom_left - top[i]) * (1 << shift) + dx * (i + 1);         \
+        b[i] = (top_right - left[i]) * (1 << shift) + dy * (i + 1);         \
+    }                                                                       \
+    for (int y = 0; y < size; y++) {                                        \
+        for (int x = 0; x < size; x++)                                      \
+            dst[x] = ((top[x] + left[y] +                                   \
+                       ((a[x] * (y + 1) + b[y] * (x + 1)) >> (2 * shift)) +  \
+                       1) >> 1) & UINT8_MAX;                                 \
+        dst += stride;                                                       \
+    }                                                                       \
+}
+
+INTRA_PLANE_C(4, 2)
+INTRA_PLANE_C(8, 3)
+
+av_cold void ff_mobiclipenc_intra_pred_init(MobiClipEncIntraPredContext *c)
+{
+    c->pred[0] = intra_pred_4x4_c;
+    c->pred[1] = intra_pred_8x8_c;
+    c->plane[0] = intra_plane_4x4_c;
+    c->plane[1] = intra_plane_8x8_c;
+
+}
diff --git a/libavcodec/mobiclipencintrapred.h b/libavcodec/mobiclipencintrapred.h
new file mode 100644
index 0000000000..1cdafb281f
--- /dev/null
+++ b/libavcodec/mobiclipencintrapred.h
@@ -0,0 +1,46 @@
+/*
+ * MobiClip encoder intra prediction DSP
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#ifndef AVCODEC_MOBICLIPENCINTRAPRED_H
+#define AVCODEC_MOBICLIPENCINTRAPRED_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "libavutil/attributes.h"
+
+typedef void (*mobiclip_intra_pred_fn)(uint8_t *dst, ptrdiff_t stride,
+                                       const uint8_t *edge,
+                                       int mode, int dc);
+typedef void (*mobiclip_intra_plane_fn)(uint8_t *dst, ptrdiff_t stride,
+                                        const uint8_t *top,
+                                        const uint8_t *left,
+                                        int plane_delta);
+
+typedef struct MobiClipEncIntraPredContext {
+    mobiclip_intra_pred_fn pred[2];
+    mobiclip_intra_plane_fn plane[2];
+} MobiClipEncIntraPredContext;
+
+av_cold void ff_mobiclipenc_intra_pred_init(MobiClipEncIntraPredContext *c);
+
+#endif /* AVCODEC_MOBICLIPENCINTRAPRED_H */
diff --git a/tests/Makefile b/tests/Makefile
index 3f0132dd97..1b9014032e 100644
--- a/tests/Makefile
+++ b/tests/Makefile
@@ -220,6 +220,7 @@ include $(SRC_PATH)/tests/fate/lossless-audio.mak
 include $(SRC_PATH)/tests/fate/lossless-video.mak
 include $(SRC_PATH)/tests/fate/matroska.mak
 include $(SRC_PATH)/tests/fate/microsoft.mak
+include $(SRC_PATH)/tests/fate/mobiclip.mak
 include $(SRC_PATH)/tests/fate/monkeysaudio.mak
 include $(SRC_PATH)/tests/fate/mov.mak
 include $(SRC_PATH)/tests/fate/mp3.mak
diff --git a/tests/fate-run.sh b/tests/fate-run.sh
index d0726c64dd..ff329dc400 100755
--- a/tests/fate-run.sh
+++ b/tests/fate-run.sh
@@ -384,6 +384,11 @@ enc_external(){
     run ffprobe${PROGSUF}${EXECSUF} -bitexact -threads $threads $probe_opt $encfile || return
 }
 
+# Codec-specific integration helpers are kept out of the generic FATE runner.
+case "$command" in
+    mobiclip_*) . "${base}/mobiclip-fate.sh" ;;
+esac
+
 # FIXME: There is a certain duplication between the avconv-related helper
 # functions above and below that should be refactored.
 ffmpeg2="$target_exec ${target_path}/ffmpeg${PROGSUF}${EXECSUF}"
diff --git a/tests/fate/mobiclip.mak b/tests/fate/mobiclip.mak
new file mode 100644
index 0000000000..75a2043fea
--- /dev/null
+++ b/tests/fate/mobiclip.mak
@@ -0,0 +1,43 @@
+MOBICLIP_MOC5_ROUNDTRIP_DEPS = FFMPEG FFPROBE                       \
+                               LAVFI_INDEV TESTSRC2_FILTER          \
+                               WRAPPED_AVFRAME_DECODER             \
+                               RAWVIDEO_ENCODER RAWVIDEO_DECODER   \
+                               RAWVIDEO_MUXER RAWVIDEO_DEMUXER     \
+                               MOBICLIP_ENCODER MOBICLIP_DECODER   \
+                               MOBICLIP_MO_MUXER MOBICLIP_MO_DEMUXER \
+                               FILE_PROTOCOL
+
+FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)) += \
+    fate-mobiclip-moc5-roundtrip \
+    fate-mobiclip-moc5-intra-modes \
+    fate-mobiclip-moc5-qp-lambda \
+    fate-mobiclip-moc5-deferred-intra8-selector \
+    fate-mobiclip-moc5-zero-mask-intra-split \
+    fate-mobiclip-moc5-certificate-only
+fate-mobiclip-moc5-roundtrip: CMD = mobiclip_moc5_roundtrip 128 80 25 12 1 20
+fate-mobiclip-moc5-roundtrip: CMP = null
+fate-mobiclip-moc5-intra-modes: CMD = mobiclip_moc5_intra_modes
+fate-mobiclip-moc5-intra-modes: CMP = null
+fate-mobiclip-moc5-qp-lambda: CMD = mobiclip_moc5_qp_lambda
+fate-mobiclip-moc5-qp-lambda: CMP = null
+fate-mobiclip-moc5-deferred-intra8-selector: CMD = mobiclip_moc5_deferred_intra8_selector
+fate-mobiclip-moc5-deferred-intra8-selector: CMP = null
+fate-mobiclip-moc5-zero-mask-intra-split: CMD = mobiclip_moc5_zero_mask_intra_split
+fate-mobiclip-moc5-zero-mask-intra-split: CMP = null
+fate-mobiclip-moc5-certificate-only: CMD = mobiclip_moc5_certificate_only
+fate-mobiclip-moc5-certificate-only: CMP = null
+
+MOBICLIP_MOC5_ADPCM_DEPS = $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)          \
+                           SINE_FILTER ARESAMPLE_FILTER             \
+                           PCM_S16LE_MUXER PCM_S16LE_DEMUXER        \
+                           PCM_S16LE_ENCODER PCM_S16LE_DECODER      \
+                           ADPCM_IMA_MOBICLIP_WII_ENCODER           \
+                           ADPCM_IMA_MOBICLIP_WII_DECODER
+
+FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ADPCM_DEPS)) += \
+    fate-mobiclip-moc5-adpcm-roundtrip
+fate-mobiclip-moc5-adpcm-roundtrip: CMD = mobiclip_moc5_adpcm_roundtrip
+fate-mobiclip-moc5-adpcm-roundtrip: CMP = null
+
+FATE_FFMPEG_FFPROBE += $(FATE_MOBICLIP_MOC5-yes)
+fate-mobiclip: $(FATE_MOBICLIP_MOC5-yes)
diff --git a/tests/mobiclip-fate.sh b/tests/mobiclip-fate.sh
new file mode 100644
index 0000000000..2b2b13eccc
--- /dev/null
+++ b/tests/mobiclip-fate.sh
@@ -0,0 +1,396 @@
+# MobiClip integration-test helpers
+#
+# Copyright (c) 2026 Martijn Brouwer
+#
+# This file is part of FFmpeg.
+#
+# FFmpeg is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# FFmpeg is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with FFmpeg; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# This file is sourced by fate-run.sh after its generic helper functions have
+# been defined.
+
+# Encode the same deterministic source twice, validate the MOC5 structure with
+# a parser independent of libavformat, then decode and enforce a minimum PSNR.
+# The duplicate encode catches uninitialised state in both the codec and muxer.
+mobiclip_moc5_roundtrip(){
+    width=${1:-128}
+    height=${2:-80}
+    rate=${3:-25}
+    frames=${4:-12}
+    gop=${5:-1}
+    min_psnr=${6:-20}
+    profile=${7:-0}
+    compact_ki=${8:-0}
+
+    srcfile="${outdir}/${test}.source.yuv"
+    encfile="${outdir}/${test}.mo"
+    encfile2="${outdir}/${test}.second.mo"
+    decfile="${outdir}/${test}.decoded.yuv"
+    cleanfiles="$cleanfiles $srcfile $encfile $encfile2 $decfile"
+    tsrcfile=$(target_path "$srcfile")
+    tencfile=$(target_path "$encfile")
+    tencfile2=$(target_path "$encfile2")
+    tdecfile=$(target_path "$decfile")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tsrcfile" || return
+
+    for output in "$tencfile" "$tencfile2"; do
+        ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+            -video_size "${width}x${height}" -framerate "$rate" -i "$tsrcfile" \
+            -frames:v "$frames" -an -c:v mobiclip -profile:v "$profile" -qp 24 -g "$gop" \
+            -f mobiclip_mo -y "$output" || return
+    done
+
+    cmp "$encfile" "$encfile2" || {
+        echo "MobiClip/MOC5 output is not deterministic" >&2
+        return 1
+    }
+
+    sh "${base}/moc5check.sh" "$encfile" "$width" "$height" \
+        $((rate * 256)) "$frames" "$gop" "$compact_ki" || return
+
+    probe_output=$(run ffprobe${PROGSUF}${EXECSUF} -v error -count_packets \
+        -select_streams v:0 \
+        -show_entries format=format_name:stream=codec_name,width,height,r_frame_rate,time_base,duration_ts,nb_read_packets \
+        -of default=noprint_wrappers=1 "$tencfile") || return
+    printf '%s\n' "$probe_output"
+    for expected in \
+        "format_name=mobiclip_mo" \
+        "codec_name=mobiclip" \
+        "width=$width" \
+        "height=$height" \
+        "r_frame_rate=$rate/1" \
+        "time_base=1/$rate" \
+        "duration_ts=$frames" \
+        "nb_read_packets=$frames"; do
+        printf '%s\n' "$probe_output" | grep -qx "$expected" || {
+            echo "missing ffprobe invariant: $expected" >&2
+            return 1
+        }
+    done
+
+    ffmpeg -auto_conversion_filters -i "$tencfile" -map 0:v:0 \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecfile" || return
+
+    expected_size=$((width * height * 3 / 2 * frames))
+    decoded_size=$(wc -c < "$decfile")
+    [ "$decoded_size" -eq "$expected_size" ] || {
+        echo "decoded size is $decoded_size, expected $expected_size" >&2
+        return 1
+    }
+
+    psnr_result=$(tests/tiny_psnr${HOSTEXECSUF} "$srcfile" "$decfile" 1 0 0) || return
+    printf '%s\n' "$psnr_result"
+    psnr=$(expr "$psnr_result" : '.*PSNR: *\([0-9.]*\)')
+    [ -n "$psnr" ] || {
+        echo "could not parse PSNR" >&2
+        return 1
+    }
+    awk -v psnr="$psnr" -v minimum="$min_psnr" \
+        'BEGIN { exit !(psnr + 0 >= minimum + 0) }' || {
+        echo "PSNR $psnr is below required minimum $min_psnr" >&2
+        return 1
+    }
+}
+
+# Intra RDO can choose a four-4x4 prediction route even when all
+# four residual blocks quantize to zero. The i4 selector still carries the
+# split and its four prediction modes. The exact plane-offset search keeps
+# this deterministic packet at 2816 bytes. Requiring a nonzero residual mask
+# suppresses two such units without changing the aligned size, so lock the
+# packet hash as well as its size.
+mobiclip_moc5_zero_mask_intra_split(){
+    width=128
+    height=128
+    rate=25
+    encoded="${outdir}/${test}.mo"
+    decoded="${outdir}/${test}.yuv"
+    cleanfiles="$cleanfiles $encoded $decoded"
+    tencoded=$(target_path "$encoded")
+    tdecoded=$(target_path "$decoded")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v 1 -pix_fmt yuv420p -an -c:v mobiclip \
+        -profile:v moc5 -qp 24 -f mobiclip_mo -y "$tencoded" || return
+    packet_size=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=size -of csv=p=0 \
+        "$tencoded") || return
+    [ "$packet_size" -eq 2816 ] || {
+        echo "zero-mask intra-split packet is $packet_size bytes, expected 2816" >&2
+        return 1
+    }
+    packet_hash=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_packets -show_entries packet=data_hash \
+        -show_data_hash SHA256 -of csv=p=0 "$tencoded") || return
+    [ "$packet_hash" = \
+      "SHA256:0d4d5df01083266d811fd3249eadb77d3d16f1623dc664ca53bc9f6207ea0145" ] || {
+        echo "zero-mask intra-split packet hash is $packet_hash" >&2
+        return 1
+    }
+
+    ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+    [ "$(wc -c < "$decoded")" -eq $((width * height * 3 / 2)) ] || {
+        echo "zero-mask intra-split output did not decode one frame" >&2
+        return 1
+    }
+}
+
+# Exercise the I-picture mode-6/mode-7 selector on a small textured image.
+# The exact packet size also locks hierarchical 16x16 -> 8x8 ->
+# 4x4 availability propagation, while a decoder round trip catches ordering
+# mistakes between predicted modes, signed plane offsets, transform selectors,
+# and residual payloads.
+mobiclip_moc5_intra_modes(){
+    width=64
+    height=64
+    source="${outdir}/${test}.source.yuv"
+    cleanfiles="$cleanfiles $source"
+    tsource=$(target_path "$source")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=25" \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tsource" || return
+
+    for profile in 0 1; do
+        encoded="${outdir}/${test}.${profile}.mo"
+        decoded="${outdir}/${test}.${profile}.yuv"
+        cleanfiles="$cleanfiles $encoded $decoded"
+        tencoded=$(target_path "$encoded")
+        tdecoded=$(target_path "$decoded")
+
+        ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+            -video_size "${width}x${height}" -framerate 25 -i "$tsource" \
+            -frames:v 1 -an -c:v mobiclip -profile:v "$profile" -qp 24 \
+            -f mobiclip_mo -y "$tencoded" || return
+        packet_size=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+            -select_streams v:0 -show_entries packet=size -of csv=p=0 \
+            "$tencoded") || return
+        [ "$packet_size" -eq 1408 ] || {
+            echo "profile $profile intra-mode packet is $packet_size bytes, expected 1408" >&2
+            return 1
+        }
+
+        ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+            -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+            -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+        [ "$(wc -c < "$decoded")" -eq $((width * height * 3 / 2)) ] || {
+            echo "profile $profile intra-mode packet did not decode" >&2
+            return 1
+        }
+    done
+}
+
+# Choose the predicted 8x8 mode before adding the transform-selector
+# lambda, and measures transform distortion in the residual domain before
+# prediction addition can clip to uint8. Lock the complete deterministic
+# QP 48 packet because either RDO difference changes its intra decisions.
+mobiclip_moc5_deferred_intra8_selector(){
+    width=128
+    height=128
+    encoded="${outdir}/${test}.mo"
+    decoded="${outdir}/${test}.yuv"
+    cleanfiles="$cleanfiles $encoded $decoded"
+    tencoded=$(target_path "$encoded")
+    tdecoded=$(target_path "$decoded")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=25" \
+        -frames:v 1 -pix_fmt yuv420p -an -c:v mobiclip \
+        -profile:v moflex -qp 48 -f mobiclip_mo -y "$tencoded" || return
+    packet_size=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=size -of csv=p=0 \
+        "$tencoded") || return
+    [ "$packet_size" -eq 300 ] || {
+        echo "high-QP intra-RDO packet is $packet_size bytes, expected 300" >&2
+        return 1
+    }
+    packet_hash=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_packets -show_entries packet=data_hash \
+        -show_data_hash SHA256 -of csv=p=0 "$tencoded") || return
+    [ "$packet_hash" = \
+      "SHA256:a9e26bc0dc0a4660c4698904432fd1cce5239296d0d8619c8d8723219b3aeba8" ] || {
+        echo "high-QP intra-RDO packet hash is $packet_hash" >&2
+        return 1
+    }
+
+    ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+    [ "$(wc -c < "$decoded")" -eq $((width * height * 3 / 2)) ] || {
+        echo "deferred intra8-selector output did not decode one frame" >&2
+        return 1
+    }
+}
+
+# The RDO lambda follows 0.035 * exp(qp * ln(2) / 3). Lock the complete
+# QP-48 packet because small changes to the curve alter mode decisions without
+# necessarily changing the aligned packet size.
+mobiclip_moc5_qp_lambda(){
+    width=48
+    height=48
+    encoded="${outdir}/${test}.mo"
+    decoded="${outdir}/${test}.yuv"
+    cleanfiles="$cleanfiles $encoded $decoded"
+    tencoded=$(target_path "$encoded")
+    tdecoded=$(target_path "$decoded")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=25" \
+        -frames:v 1 -pix_fmt yuv420p -an -c:v mobiclip \
+        -profile:v moflex -qp 48 -g 750 -f mobiclip_mo -y "$tencoded" || return
+    packet_size=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=size -of csv=p=0 \
+        "$tencoded") || return
+    [ "$packet_size" -eq 92 ] || {
+        echo "exact-lambda packet is $packet_size bytes, expected 92" >&2
+        return 1
+    }
+    packet_hash=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_packets -show_entries packet=data_hash \
+        -show_data_hash SHA256 -of csv=p=0 "$tencoded") || return
+    [ "$packet_hash" = \
+      "SHA256:8a91cfdb20f639c91bc5cc4f5b576b984063fd6e1a2e231f95ddb6c2a0dec1ce" ] || {
+        echo "exact-lambda packet hash is $packet_hash" >&2
+        return 1
+    }
+
+    ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+    [ "$(wc -c < "$decoded")" -eq $((width * height * 3 / 2)) ] || {
+        echo "exact-lambda output did not decode one frame" >&2
+        return 1
+    }
+}
+
+# A Wii player requires an authenticated pc certificate, but JD3/JD4 do not
+# require the optional per-file cc signature.  The muxer treats certificate
+# bytes as opaque; this deterministic dummy verifies the container topology.
+mobiclip_moc5_certificate_only(){
+    width=128
+    height=80
+    rate=25
+    frames=4
+    certificate="${outdir}/${test}.certificate"
+    encfile="${outdir}/${test}.mo"
+    cleanfiles="$cleanfiles $certificate $encfile"
+    tcertificate=$(target_path "$certificate")
+    tencfile=$(target_path "$encfile")
+
+    dd if=/dev/zero of="$certificate" bs=160 count=1 2>/dev/null || return
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -an -c:v mobiclip -qp 24 -g 1 \
+        -moc5_certificate "$tcertificate" -f mobiclip_mo -y "$tencfile" || return
+
+    sh "${base}/moc5check.sh" "$encfile" "$width" "$height" \
+        $((rate * 256)) "$frames" 1 0 1 || return
+}
+
+# Exercise MOC5's Wii stereo ADPCM path.  The sample count is deliberately an
+# exact number of 256-sample blocks, so any length mismatch is container loss,
+# not encoder flush padding.
+mobiclip_moc5_adpcm_roundtrip(){
+    width=128
+    height=80
+    rate=25
+    frames=12
+    gop=1
+    sample_rate=32000
+    samples=15360
+
+    videosrc="${outdir}/${test}.source.yuv"
+    audiosrc="${outdir}/${test}.source.s16"
+    encfile="${outdir}/${test}.mo"
+    videodec="${outdir}/${test}.decoded.yuv"
+    audiodec="${outdir}/${test}.decoded.s16"
+    cleanfiles="$cleanfiles $videosrc $audiosrc $encfile $videodec $audiodec"
+    tvideosrc=$(target_path "$videosrc")
+    taudiosrc=$(target_path "$audiosrc")
+    tencfile=$(target_path "$encfile")
+    tvideodec=$(target_path "$videodec")
+    taudiodec=$(target_path "$audiodec")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tvideosrc" || return
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "sine=frequency=997:sample_rate=${sample_rate}:duration=0.48" \
+        -ac 2 -c:a pcm_s16le -f s16le -y "$taudiosrc" || return
+
+    ffmpeg -auto_conversion_filters \
+        -f rawvideo -pix_fmt yuv420p -video_size "${width}x${height}" \
+        -framerate "$rate" -i "$tvideosrc" \
+        -f s16le -ar "$sample_rate" -ac 2 -i "$taudiosrc" \
+        -map 0:v:0 -map 1:a:0 -frames:v "$frames" \
+        -c:v mobiclip -profile:v moc5 -qp 24 -g "$gop" \
+        -c:a adpcm_ima_mobiclip_wii \
+        -f mobiclip_mo -y "$tencfile" || return
+
+    sh "${base}/moc5check.sh" "$encfile" "$width" "$height" \
+        $((rate * 256)) "$frames" "$gop" || return
+
+    probe_output=$(run ffprobe${PROGSUF}${EXECSUF} -v error -count_packets \
+        -show_entries stream=index,codec_name,codec_type,width,height,sample_rate,channels,nb_read_packets \
+        -of default=noprint_wrappers=1 "$tencfile") || return
+    printf '%s\n' "$probe_output"
+    for expected in \
+        "codec_name=mobiclip" \
+        "width=$width" \
+        "height=$height" \
+        "codec_name=adpcm_ima_mobiclip_wii" \
+        "sample_rate=$sample_rate" \
+        "channels=2"; do
+        printf '%s\n' "$probe_output" | grep -qx "$expected" || {
+            echo "missing ffprobe invariant: $expected" >&2
+            return 1
+        }
+    done
+
+    ffmpeg -auto_conversion_filters -i "$tencfile" -map 0:v:0 \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tvideodec" || return
+    ffmpeg -auto_conversion_filters -i "$tencfile" -map 0:a:0 \
+        -c:a pcm_s16le -f s16le -y "$taudiodec" || return
+
+    expected_video_size=$((width * height * 3 / 2 * frames))
+    expected_audio_size=$((samples * 2 * 2))
+    [ "$(wc -c < "$videodec")" -eq "$expected_video_size" ] || {
+        echo "MOC5 lost video frames in the audio round trip" >&2
+        return 1
+    }
+    [ "$(wc -c < "$audiodec")" -eq "$expected_audio_size" ] || {
+        echo "MOC5 changed the ADPCM round-trip sample count" >&2
+        return 1
+    }
+
+    psnr_result=$(tests/tiny_psnr${HOSTEXECSUF} "$audiosrc" "$audiodec" 2 0 0) || return
+    printf '%s\n' "$psnr_result"
+    psnr=$(expr "$psnr_result" : '.*PSNR: *\([0-9.]*\)')
+    [ -n "$psnr" ] || return 1
+    awk -v psnr="$psnr" 'BEGIN { exit !(psnr + 0 >= 25) }' || {
+        echo "ADPCM PSNR $psnr is below required minimum 25" >&2
+        return 1
+    }
+}
diff --git a/tests/moc5check.sh b/tests/moc5check.sh
new file mode 100644
index 0000000000..813b94ecbb
--- /dev/null
+++ b/tests/moc5check.sh
@@ -0,0 +1,270 @@
+#!/bin/sh
+#
+# Copyright (c) 2026 Martijn Brouwer
+#
+# This file is part of FFmpeg.
+#
+# FFmpeg is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# FFmpeg is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with FFmpeg; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# Validate the parts of the MOC5 container which can be checked without
+# involving libavformat.  Keeping this independent of the demuxer makes the
+# round-trip test capable of catching a matching muxer/demuxer bug.
+#
+# Usage: moc5check.sh file width height fps_fixed frames [gop] [compact_ki]
+#                     [certificate_mode]
+# certificate_mode: 0 = no certificate, 1 = pc certificate only
+
+set -eu
+
+file=$1
+expected_width=$2
+expected_height=$3
+expected_fps_fixed=$4
+expected_frames=$5
+expected_gop=${6:-0}
+expected_compact_ki=${7:-0}
+expected_certificate_mode=${8:-0}
+
+fail()
+{
+    echo "moc5check: $*" >&2
+    exit 1
+}
+
+file_size=$(wc -c < "$file")
+
+read_bytes()
+{
+    # od is part of the POSIX host tool set already required by FATE.
+    od -An -v -tu1 -j "$2" -N "$3" "$1"
+}
+
+read_u16le()
+{
+    set -- $(read_bytes "$1" "$2" 2)
+    [ "$#" -eq 2 ] || fail "short read at offset $2"
+    echo $(( $1 | ($2 << 8) ))
+}
+
+read_u32le()
+{
+    set -- $(read_bytes "$1" "$2" 4)
+    [ "$#" -eq 4 ] || fail "short read at offset $2"
+    echo $(( $1 | ($2 << 8) | ($3 << 16) | ($4 << 24) ))
+}
+
+magic=$(dd if="$file" bs=1 count=4 2>/dev/null)
+[ "$magic" = MOC5 ] || fail "bad magic '$magic'"
+
+header_size=$(read_u32le "$file" 4)
+data_start=$((header_size + 8))
+[ "$data_start" -ge 28 ] || fail "header ends too early ($data_start)"
+[ "$data_start" -le "$file_size" ] || fail "header exceeds file size"
+
+# Two-byte little-endian marker values.
+MARKER_TL=$((84 | (76 << 8)))
+MARKER_V2=$((86 | (50 << 8)))
+MARKER_KI=$((75 | (73 << 8)))
+MARKER_HE=$((72 | (69 << 8)))
+MARKER_A8=$((65 | (56 << 8)))
+MARKER_A9=$((65 | (57 << 8)))
+MARKER_A0=$((65 | (48 << 8)))
+MARKER_PC=$((112 | (99 << 8)))
+MARKER_CC=$((99 | (99 << 8)))
+
+offset=8
+seen_tl=0
+seen_v2=0
+seen_he=0
+audio_marker=0
+seen_no_audio_marker=0
+audio_channels=0
+key_count=0
+previous_key_frame=-1
+previous_key_offset=-1
+last_marker=0
+seen_pc=0
+seen_cc=0
+
+while [ "$offset" -lt "$data_start" ]; do
+    [ $((offset + 4)) -le "$data_start" ] || fail "truncated header entry at $offset"
+    marker=$(read_u16le "$file" "$offset")
+    words=$(read_u16le "$file" $((offset + 2)))
+    payload_size=$((words * 4))
+    payload=$((offset + 4))
+    next=$((payload + payload_size))
+    [ "$next" -le "$data_start" ] || fail "header entry at $offset exceeds header"
+
+    case "$marker" in
+        "$MARKER_PC")
+            [ "$payload_size" -eq 160 ] || fail "pc entry is $payload_size bytes, expected 160"
+            seen_pc=$((seen_pc + 1))
+            ;;
+        "$MARKER_CC")
+            [ "$payload_size" -eq 64 ] || fail "cc entry is $payload_size bytes, expected 64"
+            seen_cc=$((seen_cc + 1))
+            ;;
+        "$MARKER_TL")
+            [ "$words" -eq 3 ] || fail "TL entry has $words words, expected 3"
+            fps_fixed=$(read_u32le "$file" "$payload")
+            frames=$(read_u32le "$file" $((payload + 4)))
+            [ "$fps_fixed" -eq "$expected_fps_fixed" ] ||
+                fail "fps_fixed is $fps_fixed, expected $expected_fps_fixed"
+            [ "$frames" -eq "$expected_frames" ] ||
+                fail "frame count is $frames, expected $expected_frames"
+            seen_tl=1
+            ;;
+        "$MARKER_V2")
+            [ "$words" -eq 2 ] || fail "V2 entry has $words words, expected 2"
+            width=$(read_u32le "$file" "$payload")
+            height=$(read_u32le "$file" $((payload + 4)))
+            [ "$width" -eq "$expected_width" ] ||
+                fail "width is $width, expected $expected_width"
+            [ "$height" -eq "$expected_height" ] ||
+                fail "height is $height, expected $expected_height"
+            [ $((width & 15)) -eq 0 ] || fail "width is not macroblock aligned"
+            [ $((height & 15)) -eq 0 ] || fail "height is not macroblock aligned"
+            seen_v2=1
+            ;;
+        "$MARKER_A8")
+            audio_marker=$marker
+            audio_channels=1
+            ;;
+        "$MARKER_A9")
+            audio_marker=$marker
+            audio_channels=2
+            ;;
+        "$MARKER_A0")
+            seen_no_audio_marker=$((seen_no_audio_marker + 1))
+            ;;
+        "$MARKER_KI")
+            [ $((payload_size % 8)) -eq 0 ] || fail "KI size is not a multiple of 8"
+            entry=$payload
+            while [ "$entry" -lt "$next" ]; do
+                key_offset=$(read_u32le "$file" "$entry")
+                key_frame=$(read_u32le "$file" $((entry + 4)))
+                [ "$key_frame" -lt "$expected_frames" ] ||
+                    fail "key frame $key_frame is outside the stream"
+                [ "$key_frame" -gt "$previous_key_frame" ] ||
+                    fail "key frames are not strictly increasing"
+                [ "$key_offset" -gt "$previous_key_offset" ] ||
+                    fail "key offsets are not strictly increasing"
+                [ $((key_offset & 3)) -eq 0 ] ||
+                    fail "key offset $key_offset is not 4-byte aligned"
+                if [ "$key_count" -eq 0 ]; then
+                    [ "$key_frame" -eq 0 ] || fail "first key frame is not frame zero"
+                    [ "$key_offset" -eq "$data_start" ] ||
+                        fail "first key offset does not point at the first chunk"
+                elif [ "$expected_gop" -gt 0 ]; then
+                    [ $((key_frame - previous_key_frame)) -le "$expected_gop" ] ||
+                        fail "key-frame gap exceeds GOP size $expected_gop"
+                fi
+                previous_key_frame=$key_frame
+                previous_key_offset=$key_offset
+                key_count=$((key_count + 1))
+                entry=$((entry + 8))
+            done
+            ;;
+        "$MARKER_HE")
+            [ "$words" -eq 0 ] || fail "HE entry has a payload"
+            [ "$next" -eq "$data_start" ] || fail "HE is not the final header entry"
+            if [ "$expected_compact_ki" -eq 1 ]; then
+                [ "$last_marker" -eq "$MARKER_KI" ] ||
+                    fail "KI is not immediately followed by HE"
+            fi
+            seen_he=1
+            ;;
+    esac
+
+    last_marker=$marker
+    offset=$next
+done
+
+[ "$seen_tl" -eq 1 ] || fail "missing TL entry"
+[ "$seen_v2" -eq 1 ] || fail "missing V2 entry"
+[ "$seen_he" -eq 1 ] || fail "missing terminal HE entry"
+[ "$seen_no_audio_marker" -eq 0 ] ||
+    fail "video-only MOC5 unexpectedly contains an A0 marker"
+
+case "$expected_certificate_mode" in
+    0)
+        [ "$seen_pc" -eq 0 ] || fail "unexpected pc certificate"
+        [ "$seen_cc" -eq 0 ] || fail "unexpected cc signature"
+        ;;
+    1)
+        [ "$seen_pc" -eq 1 ] || fail "expected exactly one pc certificate"
+        [ "$seen_cc" -eq 0 ] || fail "unexpected cc signature"
+        ;;
+    *)
+        fail "invalid certificate mode $expected_certificate_mode"
+        ;;
+esac
+
+if [ "$expected_gop" -gt 0 ]; then
+    [ "$key_count" -gt 0 ] || fail "empty key index"
+    [ $((expected_frames - 1 - previous_key_frame)) -lt "$expected_gop" ] ||
+        fail "last GOP exceeds size $expected_gop"
+fi
+if [ "$expected_compact_ki" -eq 1 ]; then
+    expected_header_size=$((44 + 8 * key_count))
+    [ "$data_start" -eq "$expected_header_size" ] ||
+        fail "compact no-audio header is $data_start bytes, expected $expected_header_size"
+fi
+
+offset=$data_start
+frame=0
+video_bytes=0
+audio_bytes=0
+while [ "$frame" -lt "$expected_frames" ]; do
+    [ $((offset + 8)) -le "$file_size" ] || fail "truncated chunk header for frame $frame"
+    chunk_size=$(read_u32le "$file" "$offset")
+    video_size=$(read_u32le "$file" $((offset + 4)))
+    [ "$chunk_size" -ge 8 ] || fail "frame $frame has chunk size $chunk_size"
+
+    # Skip frames may consume the first four bytes of the nominal pad,
+    # hence the format's upper bound is chunk_size - 4 rather than - 8.
+    [ "$video_size" -le $((chunk_size - 4)) ] ||
+        fail "frame $frame video size exceeds its chunk"
+
+
+    raw_end=$((offset + chunk_size))
+    [ "$raw_end" -le "$file_size" ] || fail "frame $frame chunk exceeds file"
+    pad=$((4 - (raw_end % 4)))
+    next=$((raw_end + pad))
+    [ "$next" -le "$file_size" ] || fail "frame $frame padding exceeds file"
+    [ $((next & 3)) -eq 0 ] || fail "frame $frame next chunk is unaligned"
+
+    audio_size=$((chunk_size - video_size - 8))
+    [ "$audio_size" -ge 0 ] || audio_size=0
+    if [ "$audio_size" -gt 0 ] &&
+       { [ "$audio_marker" -eq "$MARKER_A8" ] || [ "$audio_marker" -eq "$MARKER_A9" ]; }; then
+        block_size=$((audio_channels * 132))
+        [ $(((audio_size + pad) % block_size)) -eq 0 ] ||
+            fail "frame $frame ADPCM payload plus pad is not block aligned"
+    fi
+
+    video_bytes=$((video_bytes + video_size))
+    audio_bytes=$((audio_bytes + audio_size))
+    offset=$next
+    frame=$((frame + 1))
+done
+
+[ $((file_size - offset)) -eq 4 ] ||
+    fail "expected a four-byte terminal chunk, found $((file_size - offset)) bytes"
+[ "$(read_u32le "$file" "$offset")" -eq 0 ] ||
+    fail "terminal chunk is not zero"
+
+echo "moc5: frames=$frame keys=$key_count video_bytes=$video_bytes" \
+     "audio_bytes=$audio_bytes size=$file_size"
-- 
2.52.0


>From 9fe6a9e74719f46d147226b720227530f7a74b3d Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Fri, 31 Jul 2026 00:18:32 +0200
Subject: [PATCH 07/10] avcodec/mobiclipenc: add inter prediction and reference
 frames

Extend the encoder with predictive frames and reconstructed reference management.

Add full-pel and half-pel motion search, direct and explicit motion modes, recursive partition trees, P-frame intra blocks, up to five reference ages, and optional mixed-reference leaves.

Keep packet output deterministic while using the thread budget to construct current-frame reference data. Add coverage for inter syntax, partition selection, multiple references, and reconstruction drift.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 configure                   |    2 +-
 doc/encoders.texi           |   20 +
 libavcodec/mobiclipenc.c    | 2179 ++++++++++++++++++++++++++++++++++-
 libavcodec/mobiclipencdsp.c |   65 +-
 libavcodec/mobiclipencdsp.h |   17 +-
 tests/fate/mobiclip.mak     |   34 +-
 tests/mobiclip-fate.sh      |  684 ++++++++++-
 7 files changed, 2975 insertions(+), 26 deletions(-)

diff --git a/configure b/configure
index e672f579ae..e250693864 100755
--- a/configure
+++ b/configure
@@ -3224,7 +3224,7 @@ mjpegb_decoder_select="hpeldsp mjpeg_decoder"
 mlp_decoder_select="mlp_parser"
 mlp_encoder_select="lpc audio_frame_queue"
 mobiclip_decoder_select="bswapdsp golomb"
-mobiclip_encoder_select="golomb"
+mobiclip_encoder_select="golomb pixelutils"
 motionpixels_decoder_select="bswapdsp"
 mp1_decoder_select="mpegaudio"
 mp1float_decoder_select="mpegaudio"
diff --git a/doc/encoders.texi b/doc/encoders.texi
index 9cbc6e6e52..bd918bdbe6 100644
--- a/doc/encoders.texi
+++ b/doc/encoders.texi
@@ -3564,6 +3564,14 @@ multiples of 16.
 @subsection Options
 
 @table @option
+@item refs @var{integer}
+Set the number of reconstructed reference frames, from 1 to 5. The default is
+5.
+
+@item mixed_refs @var{boolean}
+Allow each explicitly coded leaf of a split macroblock to select its own
+reference age. This is disabled by default.
+
 @item qp @var{integer}
 Set a fixed quantizer written directly to the MobiClip bitstream. The valid
 range is 12 to 63. Lower values retain more detail and usually produce larger
@@ -3572,8 +3580,20 @@ files. When no quantizer is set, the encoder uses quantizer 40.
 @item profile @var{profile}
 Select @code{moc5} for the Wii MO bitstream profile or @code{moflex} for the
 flexible profile. The default is @code{moflex}.
+
+@item g @var{integer}
+Set the maximum interval between keyframes. Forced keyframes may occur sooner
+and restart the interval.
+
+@item threads @var{integer}
+Set the encoder thread budget. A value of 0, the default, uses the number of
+logical processors reported by the host. Additional threads are used only to
+construct the current frame's references, so packet output remains bounded to
+one input frame.
 @end table
 
+With @option{flags +psnr}, the encoder records component SSE in
+@code{AVCodecContext.error} and logs aggregate reconstruction SSE and PSNR.
 @section Microsoft RLE
 
 Microsoft RLE aka MSRLE encoder.
diff --git a/libavcodec/mobiclipenc.c b/libavcodec/mobiclipenc.c
index 5919df6735..c63474d7ac 100644
--- a/libavcodec/mobiclipenc.c
+++ b/libavcodec/mobiclipenc.c
@@ -20,16 +20,19 @@
  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
  */
 
+#include <inttypes.h>
 #include <math.h>
 #include <stddef.h>
 #include <stdint.h>
-#include <string.h>
 
 #include "libavutil/common.h"
+#include "libavutil/cpu.h"
 #include "libavutil/imgutils.h"
 #include "libavutil/internal.h"
 #include "libavutil/mem.h"
 #include "libavutil/opt.h"
+#include "libavutil/pixelutils.h"
+#include "libavutil/slicethread.h"
 
 #include "avcodec.h"
 #include "codec_internal.h"
@@ -54,18 +57,51 @@ typedef struct MobiEncUnit {
     int transform8;
 } MobiEncUnit;
 
+typedef struct MobiEncPartition {
+    int x, y;
+    int width, height;
+    int mode;
+    int mv_x, mv_y;
+    int seed_x, seed_y;
+    int child[2];
+    int local_cost;
+} MobiEncPartition;
+
 #define MOBI_COST_INF 0x10000000
 #define MOBI_QP_BITS 6
 #define MOBI_QP_PERIOD 6
 #define MOBI_MIN_QP 12
 #define MOBI_MAX_QP ((1 << MOBI_QP_BITS) - 1)
 #define MOBI_DEFAULT_QP 40
+#define MOBI_DEFAULT_GOP_SIZE 0
+#define MOBI_MAX_REFERENCES 5
+#define MOBI_REFERENCE_SLOTS (MOBI_MAX_REFERENCES + 1)
+#define MOBI_INTER_UNITS (4 + 2)
+#define MOBI_RDO_BUFFER_SIZE 4096
 #define MOBI_RL_COST_FULL_ESCAPE 63
 #define MOBI_RL_COST_MAGNITUDES (MOBI_RL_COST_FULL_ESCAPE + 1)
 #define MOBI_NEUTRAL_CHROMA (1 << 7)
+#define MOBI_PARTITION_SEED_POSITIONS 64
+#define MOBI_MAX_PARTITION_NODES (2 * MOBI_PARTITION_SEED_POSITIONS - 1)
 #define MOBI_LAMBDA_SCALE 0.035
 #define MOBI_QP_EXPONENT_STEP (M_LN2 / 3.0)
 
+typedef struct MobiPartitionSeed {
+    int x, y;
+} MobiPartitionSeed;
+
+typedef struct MobiEncMacroblock {
+    MobiEncUnit unit[MOBI_INTER_UNITS];
+    unsigned component_mask;
+    int mv_x;
+    int mv_y;
+    int mode;
+    int bits;
+    int motion_bits;
+    MobiEncPartition partition[MOBI_MAX_PARTITION_NODES];
+    int partition_count;
+} MobiEncMacroblock;
+
 typedef struct MobiEncMacroblockPixels {
     uint8_t luma[16 * 16];
     uint8_t chroma[2][8 * 8];
@@ -94,17 +130,38 @@ typedef struct MobiIntraMacroblock {
 
 typedef struct MobiClipEncContext {
     const AVClass *class;
+    AVFrame *reference[MOBI_REFERENCE_SLOTS];
+    /*
+     * Motion seeds persist for each partition shape and position.  Direct
+     * modes do not update them, so later searches reuse the most recent
+     * explicit-search delta.
+     */
+    MobiPartitionSeed
+        partition_seed[16][MOBI_PARTITION_SEED_POSITIONS];
+    uint8_t *me_reference[MOBI_REFERENCE_SLOTS];
+    uint8_t *halfpel_reference[MOBI_REFERENCE_SLOTS][3];
+    uint16_t *me_column_sum;
+    ptrdiff_t me_stride;
+    ptrdiff_t halfpel_stride;
     AVFrame *recon;
+    int current_reference;
+    int frames_since_key;
+    int mixed_refs;
     int qp;
     int lambda;
     int motion_lambda;
     int qdiv;
     int qrem;
     int moflex;
+    int previous_qp;
+    int64_t frame_count;
+    int64_t last_key_frame;
+    long double total_sse;
     int qtab[16];
     int16_t qmul4[16];
     int16_t qmul8[64];
     int16_t dequant8[64];
+    av_pixelutils_sad_fn sad[4];
     uint16_t rl_code[2][1 << 12];
     uint8_t rl_len[2][1 << 12];
     uint8_t rl_level_residue[2][2][64];
@@ -113,9 +170,18 @@ typedef struct MobiClipEncContext {
     uint8_t intra_context[20];
     MobiClipEncDSPContext dsp;
     MobiClipEncIntraPredContext intra_pred;
+    AVSliceThread *reference_threads;
+    const AVFrame *reference_source;
+    int reference_slot;
     AVFrame *input;
+    struct {
+        int x, y;
+    } *motion;
 } MobiClipEncContext;
 
+static void build_reference_worker(void *priv, int jobnr, int threadnr,
+                                   int nb_jobs, int nb_threads);
+
 static uint64_t mobi_lambda(int qp)
 {
     return (uint64_t)(FFMAX(1.0, MOBI_LAMBDA_SCALE *
@@ -146,7 +212,107 @@ static void mobi_set_qp(MobiClipEncContext *s, int qp)
  * 16x16, 16x8, 16x4, 16x2, 8x16, ..., 2x2.  Symbols 6 and 7
  * are the P-picture intra modes and are not legal below the root.
  */
+static const uint8_t mobi_partition_code[2][16][10] = {
+    {
+        { 3, 0, 9, 17, 16, 22, 23, 10, 2, 3 },
+        { 0, 3, 4, 10, 5, 4, 0, 0, 3, 11 },
+        { 0, 3, 4, 2, 7, 10, 0, 0, 6, 11 },
+        { 6, 0, 5, 15, 9, 14, 0, 0, 0, 8 },
+        { 0, 3, 4, 10, 7, 6, 0, 0, 11, 2 },
+        { 0, 3, 5, 2, 8, 6, 0, 0, 9, 7 },
+        { 5, 3, 0, 3, 9, 8, 0, 0, 4, 5 },
+        { 5, 3, 0, 4, 2, 7, 0, 0, 0, 6 },
+        { 1, 3, 5, 0, 8, 3, 0, 0, 9, 2 },
+        { 0, 3, 5, 2, 9, 7, 0, 0, 8, 6 },
+        { 0, 3, 5, 3, 2, 9, 0, 0, 16, 17 },
+        { 0, 3, 5, 4, 2, 7, 0, 0, 0, 6 },
+        { 7, 0, 6, 4, 10, 23, 0, 0, 22, 0 },
+        { 0, 3, 1, 4, 10, 23, 0, 0, 22, 0 },
+        { 0, 3, 5, 4, 2, 7, 0, 0, 6, 0 },
+        { 2, 1, 7, 6, 1, 0, 0, 0, 0, 0 },
+    },
+    {
+        { 1, 2, 2, 6, 27, 26, 7, 12, 0, 7 },
+        { 5, 3, 0, 8, 19, 18, 0, 0, 1, 1 },
+        { 0, 3, 3, 10, 5, 4, 0, 0, 4, 11 },
+        { 6, 0, 5, 15, 9, 8, 0, 0, 0, 14 },
+        { 7, 2, 2, 6, 15, 14, 0, 0, 6, 0 },
+        { 5, 3, 2, 0, 3, 2, 0, 0, 4, 3 },
+        { 5, 0, 4, 14, 31, 30, 0, 0, 13, 12 },
+        { 6, 0, 5, 15, 14, 9, 0, 0, 0, 8 },
+        { 0, 3, 3, 10, 5, 4, 0, 0, 11, 4 },
+        { 5, 0, 4, 14, 31, 30, 0, 0, 13, 12 },
+        { 0, 3, 5, 2, 9, 8, 0, 0, 7, 6 },
+        { 0, 3, 5, 3, 9, 2, 0, 0, 0, 8 },
+        { 7, 0, 5, 13, 9, 8, 0, 0, 12, 0 },
+        { 6, 0, 5, 15, 14, 9, 0, 0, 8, 0 },
+        { 0, 3, 5, 3, 2, 9, 0, 0, 8, 0 },
+        { 0, 3, 5, 4, 2, 3, 0, 0, 0, 0 },
+    },
+};
 
+static const uint8_t mobi_partition_len[2][16][10] = {
+    {
+        { 2, 2, 4, 5, 5, 5, 5, 4, 3, 3 },
+        { 2, 2, 3, 4, 4, 4, 0, 0, 3, 4 },
+        { 2, 2, 3, 3, 4, 4, 0, 0, 4, 4 },
+        { 3, 1, 3, 4, 4, 4, 0, 0, 0, 4 },
+        { 2, 2, 3, 4, 4, 4, 0, 0, 4, 3 },
+        { 2, 2, 3, 3, 4, 4, 0, 0, 4, 4 },
+        { 3, 2, 2, 3, 4, 4, 0, 0, 4, 4 },
+        { 3, 2, 2, 3, 3, 4, 0, 0, 0, 4 },
+        { 2, 2, 3, 3, 4, 4, 0, 0, 4, 4 },
+        { 2, 2, 3, 3, 4, 4, 0, 0, 4, 4 },
+        { 2, 2, 3, 3, 3, 4, 0, 0, 5, 5 },
+        { 2, 2, 3, 3, 3, 4, 0, 0, 0, 4 },
+        { 3, 1, 3, 3, 4, 5, 0, 0, 5, 0 },
+        { 2, 2, 2, 3, 4, 5, 0, 0, 5, 0 },
+        { 2, 2, 3, 3, 3, 4, 0, 0, 4, 0 },
+        { 2, 2, 3, 3, 3, 3, 0, 0, 0, 0 },
+    },
+    {
+        { 1, 3, 4, 5, 6, 6, 5, 5, 3, 4 },
+        { 3, 2, 3, 4, 5, 5, 0, 0, 2, 3 },
+        { 2, 2, 3, 4, 4, 4, 0, 0, 3, 4 },
+        { 3, 1, 3, 4, 4, 4, 0, 0, 0, 4 },
+        { 3, 2, 3, 4, 5, 5, 0, 0, 3, 2 },
+        { 3, 2, 3, 3, 4, 4, 0, 0, 3, 3 },
+        { 3, 1, 3, 4, 5, 5, 0, 0, 4, 4 },
+        { 3, 1, 3, 4, 4, 4, 0, 0, 0, 4 },
+        { 2, 2, 3, 4, 4, 4, 0, 0, 4, 3 },
+        { 3, 1, 3, 4, 5, 5, 0, 0, 4, 4 },
+        { 2, 2, 3, 3, 4, 4, 0, 0, 4, 4 },
+        { 2, 2, 3, 3, 4, 3, 0, 0, 0, 4 },
+        { 3, 1, 3, 4, 4, 4, 0, 0, 4, 0 },
+        { 3, 1, 3, 4, 4, 4, 0, 0, 4, 0 },
+        { 2, 2, 3, 3, 3, 4, 0, 0, 4, 0 },
+        { 2, 2, 3, 3, 3, 3, 0, 0, 0, 0 },
+    },
+};
+
+static int mobi_partition_class(int width, int height)
+{
+    int width_index  = width  == 16 ? 0 : width  == 8 ? 1 :
+                       width  == 4  ? 2 : 3;
+    int height_index = height == 16 ? 0 : height == 8 ? 1 :
+                       height == 4  ? 2 : 3;
+
+    return width_index * 4 + height_index;
+}
+
+static MobiPartitionSeed *mobi_partition_seed(
+    MobiPartitionSeed seed[16][MOBI_PARTITION_SEED_POSITIONS],
+    int x, int y, int width, int height)
+{
+    int position = ((y & 15) >> 1) * 8 + ((x & 15) >> 1);
+
+    return &seed[mobi_partition_class(width, height)][position];
+}
+
+/*
+ * The quantization tables use natural coefficient order.  Both 8x8 matrices
+ * repeat six values according to this category map.
+ */
 static const uint8_t mobi_8x8_category[64] = {
     0, 1, 2, 1, 0, 1, 2, 1,
     1, 3, 4, 3, 1, 3, 4, 3,
@@ -1319,6 +1485,97 @@ static int evaluate_intra_macroblock(MobiClipEncContext *s,
     return 1;
 }
 
+static int prepare_inter_unit_internal(MobiClipEncContext *s,
+                                       AVFrame *working,
+                                       const AVFrame *src,
+                                       int plane, int x, int y,
+                                       MobiEncUnit *unit, int commit)
+{
+    uint8_t *dst = working->data[plane] +
+                   y * working->linesize[plane] + x;
+    const uint8_t *input = src->data[plane] +
+                           y * src->linesize[plane] + x;
+    uint8_t prediction[64], recon8[64], recon4[64];
+    MobiEncUnit candidate4 = { 0 };
+    MobiEncBlock block8;
+    int lambda = s->lambda;
+    int best_cost, cost4 = 0;
+    int complete = 1;
+
+    for (int row = 0; row < 8; row++) {
+        memcpy(prediction + row * 8,
+               dst + row * working->linesize[plane], 8);
+        memcpy(recon4 + row * 8, prediction + row * 8, 8);
+    }
+
+    best_cost = evaluate_residual8x8(s, input, src->linesize[plane],
+                                     prediction, 8, recon8, &block8, 0);
+    if (block8.has_coeffs)
+        best_cost += lambda; /* ue(0) transform selector */
+
+    for (int by = 0, n = 0; by < 8 && complete; by += 4) {
+        for (int bx = 0; bx < 8; bx += 4, n++) {
+            cost4 += evaluate_residual4x4(
+                s, input + by * src->linesize[plane] + bx,
+                src->linesize[plane], prediction + by * 8 + bx, 8,
+                recon4 + by * 8 + bx, 8, &candidate4.block[n], 0);
+            if (candidate4.block[n].has_coeffs)
+                candidate4.mask |= 1U << n;
+            if (cost4 >= best_cost) {
+                complete = 0;
+                break;
+            }
+        }
+    }
+    if (complete) {
+        int index = find_mask(ff_mobiclip_p4_masks,
+                              FF_ARRAY_ELEMS(ff_mobiclip_p4_masks),
+                              candidate4.mask);
+
+        av_assert2(index >= 0);
+        cost4 += mobi_ue_bits(index) * lambda;
+    }
+
+    if (complete && cost4 < best_cost) {
+        *unit = candidate4;
+        unit->transform8 = 0;
+        if (commit)
+            for (int row = 0; row < 8; row++)
+                memcpy(dst + row * working->linesize[plane],
+                       recon4 + row * 8, 8);
+        return cost4;
+    } else {
+        memset(unit, 0, sizeof(*unit));
+        unit->block[0] = block8;
+        unit->mask = block8.has_coeffs;
+        unit->transform8 = block8.has_coeffs;
+        if (commit)
+            for (int row = 0; row < 8; row++)
+                memcpy(dst + row * working->linesize[plane],
+                       recon8 + row * 8, 8);
+        return best_cost;
+    }
+}
+
+static int prepare_inter_unit(MobiClipEncContext *s, const AVFrame *src,
+                              int plane, int x, int y,
+                              MobiEncUnit *unit)
+{
+    return prepare_inter_unit_internal(s, s->recon, src,
+                                       plane, x, y, unit, 1);
+}
+
+static int evaluate_inter_unit_cost(MobiClipEncContext *s,
+                                    AVFrame *working,
+                                    const AVFrame *src,
+                                    int plane, int x, int y)
+{
+    MobiEncUnit unit;
+
+    return prepare_inter_unit_internal(s, working, src,
+                                       plane, x, y, &unit, 0);
+}
+
 static void put_coefficients(const MobiClipEncContext *s, PutBitContext *pb,
                              const int16_t *level, int count, int table)
 {
@@ -1474,6 +1731,846 @@ static void put_predicted_intra_unit(const MobiClipEncContext *s,
     }
 }
 
+static void put_inter_unit(const MobiClipEncContext *s, PutBitContext *pb,
+                           const MobiEncUnit *unit)
+{
+    av_assert2(unit->mask);
+    if (unit->transform8) {
+        set_ue_golomb(pb, 0);
+        put_coefficients(s, pb, unit->block[0].level, 64, 0);
+    } else {
+        int index = find_mask(ff_mobiclip_p4_masks,
+                              FF_ARRAY_ELEMS(ff_mobiclip_p4_masks),
+                              unit->mask);
+
+        av_assert2(index > 0);
+        set_ue_golomb(pb, index);
+        for (int i = 0; i < 4; i++)
+            if (unit->mask & (1U << i))
+                put_coefficients(s, pb, unit->block[i].level, 16, 0);
+    }
+}
+
+static int median3(int a, int b, int c)
+{
+    if (a > b)
+        FFSWAP(int, a, b);
+    if (b > c)
+        FFSWAP(int, b, c);
+    return FFMAX(a, b);
+}
+
+static int block_sad_halfpel(const MobiClipEncContext *s, const AVFrame *src,
+                             const AVFrame *ref,
+                             uint8_t *const halfpel[3], int x, int y,
+                             int width, int height, int mv_x, int mv_y)
+{
+    const uint8_t *a = src->data[0] + y * src->linesize[0] + x;
+    int method = (mv_x & 1) | ((mv_y & 1) << 1);
+    ptrdiff_t stride = method ? s->halfpel_stride : ref->linesize[0];
+    const uint8_t *b = (method ? halfpel[method - 1] : ref->data[0]) +
+                       (y + (mv_y >> 1)) * stride + x + (mv_x >> 1);
+    int width_index = av_log2(width) - 2;
+    int height_index = av_log2(height) - 2;
+    int block_size = FFMIN(width, height);
+    int size_bits = av_log2(block_size);
+    int sad = 0;
+
+    if ((unsigned)width_index < 3 && (unsigned)height_index < 3)
+        return s->dsp.sad[width_index][height_index](
+            a, src->linesize[0], b, stride);
+
+    av_assert2(size_bits >= 1 && size_bits <= 4);
+    for (int by = 0; by < height; by += block_size)
+        for (int bx = 0; bx < width; bx += block_size)
+            sad += s->sad[size_bits - 1](
+                a + by * src->linesize[0] + bx, src->linesize[0],
+                b + by * stride + bx, stride);
+    return sad;
+}
+
+static int motion_vector_bits(int delta)
+{
+    unsigned magnitude = FFABS(delta) + 1;
+    int order = magnitude > 1 ? av_log2(magnitude - 1) + 1 : 0;
+
+    return 1 + 2 * order;
+}
+
+static int motion_search_cost(const MobiClipEncContext *s, const AVFrame *src,
+                              const AVFrame *ref,
+                              uint8_t *const halfpel[3], int x, int y,
+                              int width, int height, int mv_x, int mv_y,
+                              int pred_x, int pred_y, int lambda)
+{
+    return block_sad_halfpel(s, src, ref, halfpel, x, y, width, height,
+                             mv_x, mv_y) +
+           lambda * (motion_vector_bits(mv_x - pred_x) +
+                     motion_vector_bits(mv_y - pred_y));
+}
+
+static int try_motion_candidate(const MobiClipEncContext *s,
+                                const AVFrame *src, const AVFrame *ref,
+                                uint8_t *const halfpel[3],
+                                int x, int y,
+                                int width, int height,
+                                int candidate_x, int candidate_y,
+                                int min_x, int max_x, int min_y, int max_y,
+                                int pred_x, int pred_y, int lambda,
+                                int *best_x, int *best_y, int *best_cost)
+{
+    int cost;
+
+    candidate_x = av_clip(candidate_x, min_x, max_x);
+    candidate_y = av_clip(candidate_y, min_y, max_y);
+    cost = motion_search_cost(s, src, ref, halfpel, x, y, width, height,
+                              candidate_x, candidate_y, pred_x, pred_y,
+                              lambda);
+    if (cost >= *best_cost)
+        return 0;
+
+    *best_x = candidate_x;
+    *best_y = candidate_y;
+    *best_cost = cost;
+    return 1;
+}
+
+static void motion_cost_x4(const MobiClipEncContext *s,
+                           const AVFrame *src, const AVFrame *ref,
+                           uint8_t *const halfpel[3],
+                           int x, int y, int width, int height,
+                           const int candidate[4][2],
+                           int pred_x, int pred_y, int lambda,
+                           int cost[4])
+{
+    const uint8_t *source =
+        src->data[0] + y * src->linesize[0] + x;
+    const uint8_t *reference[4];
+    int sad[4];
+    int method = (candidate[0][0] & 1) |
+                 ((candidate[0][1] & 1) << 1);
+    ptrdiff_t stride = method ? s->halfpel_stride : ref->linesize[0];
+    const uint8_t *plane = method ? halfpel[method - 1] : ref->data[0];
+
+    for (int lane = 0; lane < 4; lane++) {
+        av_assert2(((candidate[lane][0] & 1) |
+                    ((candidate[lane][1] & 1) << 1)) == method);
+        reference[lane] =
+            plane + (y + (candidate[lane][1] >> 1)) * stride +
+            x + (candidate[lane][0] >> 1);
+    }
+    s->dsp.sad_x4(sad, source, src->linesize[0], reference, stride,
+                  width, height);
+    for (int lane = 0; lane < 4; lane++)
+        cost[lane] = sad[lane] +
+                     lambda * (
+                         motion_vector_bits(candidate[lane][0] - pred_x) +
+                         motion_vector_bits(candidate[lane][1] - pred_y));
+}
+
+static void build_me_reference(const AVFrame *src, uint8_t *dst,
+                               ptrdiff_t dst_stride, uint16_t *column_sum)
+{
+    const int width  = src->width;
+    const int height = src->height;
+
+    memset(column_sum, 0, width * sizeof(*column_sum));
+    for (int row = 0; row < 8; row++) {
+        const uint8_t *line = src->data[0] + row * src->linesize[0];
+
+        for (int x = 0; x < width; x++)
+            column_sum[x] += line[x];
+    }
+
+    /*
+     * Store vertically averaged windows beginning at rows 0..height-8.
+     * Every output row starts with a rounding carry of four, propagated
+     * continuously from the leftmost through the rightmost column.
+     */
+    for (int y = 0; y <= height - 8; y++) {
+        int carry = 4;
+
+        if (y) {
+            const uint8_t *enter =
+                src->data[0] + (y + 7) * src->linesize[0];
+            const uint8_t *leave =
+                src->data[0] + (y - 1) * src->linesize[0];
+
+            for (int x = 0; x < width; x++)
+                column_sum[x] += enter[x] - leave[x];
+        }
+
+        for (int x = 0; x < width; x++) {
+            int value = (column_sum[x] + carry) >> 3;
+
+            dst[y * dst_stride + x] = value;
+            carry += column_sum[x] - 8 * value;
+        }
+    }
+}
+
+static void build_halfpel_reference(const MobiClipEncDSPContext *dsp,
+                                    const AVFrame *src,
+                                    uint8_t *const halfpel[3],
+                                    ptrdiff_t stride)
+{
+    const int width  = src->width;
+    const int height = src->height;
+
+    for (int y = 0; y < height; y++) {
+        const uint8_t *line = src->data[0] + y * src->linesize[0];
+        uint8_t *horizontal = halfpel[0] + y * stride;
+
+        dsp->halfpel_filter(horizontal, line, line + 1, width - 1);
+    }
+    for (int y = 0; y < height - 1; y++) {
+        const uint8_t *line0 = src->data[0] + y * src->linesize[0];
+        const uint8_t *line1 = line0 + src->linesize[0];
+        const uint8_t *horizontal0 = halfpel[0] + y * stride;
+        const uint8_t *horizontal1 = horizontal0 + stride;
+        uint8_t *vertical = halfpel[1] + y * stride;
+        uint8_t *diagonal = halfpel[2] + y * stride;
+
+        dsp->halfpel_filter(vertical, line0, line1, width);
+        dsp->halfpel_filter(diagonal, horizontal0, horizontal1, width - 1);
+    }
+}
+
+static void build_reference_worker(void *priv, int jobnr, int threadnr,
+                                   int nb_jobs, int nb_threads)
+{
+    MobiClipEncContext *s = priv;
+    const AVFrame *src = s->reference_source;
+    int slot = s->reference_slot;
+
+    if (!jobnr) {
+        build_me_reference(src, s->me_reference[slot],
+                           s->me_stride, s->me_column_sum);
+    } else {
+        build_halfpel_reference(&s->dsp, src, s->halfpel_reference[slot],
+                                s->halfpel_stride);
+    }
+}
+
+static void coarse_cost_x4(const MobiClipEncContext *s,
+                           const uint8_t source[2][16],
+                           int width, int bands,
+                           const uint8_t *reference,
+                           ptrdiff_t reference_stride,
+                           int x, int y, const int candidate[4][2],
+                           int pred_x, int pred_y, int lambda,
+                           int cost[4])
+{
+    const uint8_t *candidate_ref[4];
+    int sad[4];
+
+    for (int lane = 0; lane < 4; lane++)
+        candidate_ref[lane] =
+            reference + (y + candidate[lane][1] / 2) * reference_stride +
+            x + candidate[lane][0] / 2;
+    s->dsp.sad_x4(sad, source[0], sizeof(source[0]), candidate_ref,
+                  8 * reference_stride, width, bands);
+    for (int lane = 0; lane < 4; lane++)
+        cost[lane] = 8 * sad[lane] +
+                     lambda * (
+                         motion_vector_bits(candidate[lane][0] - pred_x) +
+                         motion_vector_bits(candidate[lane][1] - pred_y));
+}
+
+static void search_motion_region(const MobiClipEncContext *s,
+                                 const AVFrame *src, const AVFrame *ref,
+                                 uint8_t *const halfpel[3],
+                                 const uint8_t *me_ref,
+                                 int x, int y, int width, int height,
+                                 int me_method,
+                                 int pred_x, int pred_y,
+                                 const int candidates[][2],
+                                 int candidate_count,
+                                 int caller_best_motion_cost,
+                                 int caller_reference,
+                                 int *best_x, int *best_y,
+                                 int *best_cost_out)
+{
+    static const int p_pattern[16][2] = {
+        { -4,  2 }, { -4,  1 }, { -4,  0 }, { -4, -1 },
+        { -4, -2 }, {  4, -2 }, {  4, -1 }, {  4,  0 },
+        {  4,  1 }, {  4,  2 }, {  2,  3 }, {  0,  4 },
+        { -2,  3 }, { -2, -3 }, {  0, -4 }, {  2, -3 },
+    };
+    static const int hex_pattern[6][2] = {
+        { -4,  0 }, { -2,  4 }, {  2,  4 },
+        {  4,  0 }, {  2, -4 }, { -2, -4 },
+    };
+    static const int even_refine[8][2] = {
+        { -2,  0 }, {  2,  0 }, {  0, -2 }, {  0,  2 },
+        { -2, -2 }, { -2,  2 }, {  2, -2 }, {  2,  2 },
+    };
+    static const int half_refine[4][2] = {
+        { -1, 0 }, { 1, 0 }, { 0, -1 }, { 0, 1 },
+    };
+    static const int initial_coarse[6][4][2] = {
+        { { -4,  0 }, {  4,  0 }, {   0, -4 }, {  0,  4 } },
+        { { -8,  0 }, {  8,  0 }, {   0, -8 }, {  0,  8 } },
+        { {-12,  0 }, { 12,  0 }, {   0,-12 }, {  0, 12 } },
+        { {-16,  0 }, { 16,  0 }, {   0,-16 }, {  0, 16 } },
+        { {-20,  0 }, { 20,  0 }, { -24,  0 }, { 24,  0 } },
+        { {-28,  0 }, { 28,  0 }, { -32,  0 }, { 32,  0 } },
+    };
+    static const int initial_hex_order[6] = { 5, 0, 1, 2, 3, 4 };
+    static const int initial_hex_direction[6] = { 3, 0, 1, 2, 3, 4 };
+    int seen[5][2];
+    int seen_count = 0;
+    int lambda = s->motion_lambda;
+    int min_x = FFMAX(-2 * x, pred_x - INT8_MAX);
+    int max_x = FFMIN(2 * (ref->width  - x - width), pred_x + INT8_MAX);
+    int min_y = FFMAX(-2 * y, pred_y - INT8_MAX);
+    int max_y = FFMIN(2 * (ref->height - y - height), pred_y + INT8_MAX);
+    int phase_base_min_x = min_x;
+    int phase_base_max_x = max_x;
+    int phase_base_min_y = min_y;
+    int phase_base_max_y = max_y;
+    int best_cost;
+
+    *best_x = av_clip(pred_x, min_x, max_x);
+    *best_y = av_clip(pred_y, min_y, max_y);
+    best_cost = motion_search_cost(s, src, ref, halfpel,
+                                   x, y, width, height,
+                                   *best_x, *best_y, pred_x, pred_y,
+                                   lambda);
+    seen[seen_count][0] = *best_x;
+    seen[seen_count++][1] = *best_y;
+    if (best_cost <= 2 * lambda)
+        goto done;
+
+    /* Candidate endpoints are de-duplicated in their caller-supplied order. */
+    for (int i = 0; i < candidate_count; i++) {
+        int candidate_x = candidates[i][0];
+        int candidate_y = candidates[i][1];
+        int duplicate = 0;
+
+        candidate_x = av_clip(candidate_x, min_x, max_x);
+        candidate_y = av_clip(candidate_y, min_y, max_y);
+        for (int j = 0; j < seen_count; j++)
+            if (seen[j][0] == candidate_x && seen[j][1] == candidate_y) {
+                duplicate = 1;
+                break;
+            }
+        if (duplicate)
+            continue;
+        seen[seen_count][0] = candidate_x;
+        seen[seen_count++][1] = candidate_y;
+        try_motion_candidate(s, src, ref, halfpel,
+                             x, y, width, height,
+                             candidate_x, candidate_y,
+                             min_x, max_x, min_y, max_y,
+                             pred_x, pred_y, lambda,
+                             best_x, best_y, &best_cost);
+    }
+
+    /*
+     * MeMethod=0 skips only this coarse full-pel stage.  Partition leaves
+     * narrower or shorter than four pixels use that method; both methods
+     * continue through the hexagon and sub-pel refinements.
+     */
+    if (me_method == 1) {
+        int min_even_x = (min_x + 1) & ~1;
+        int max_even_x = max_x & ~1;
+        int min_even_y = (min_y + 1) & ~1;
+        int max_even_y = max_y & ~1;
+        int saved_x, saved_y, saved_cost;
+        int full_x, full_y, full_cost;
+        int coarse_anchor_x, coarse_anchor_y;
+        int p_x, p_y;
+        uint8_t coarse_src[2][16];
+        int coarse_bands = height >> 3;
+        int screen_margin = width * height >> 4;
+        int carry = 4;
+
+        /*
+         * Use rounded-even bounds for the coarse and P-pattern stages.  The
+         * later full-pel phase adjustment starts from this already-shrunken
+         * range, not the raw bounds.  For an odd winner with raw bounds
+         * [-127, 127], the sequence is [-126, 126] then [-125, 125].
+         */
+        phase_base_min_x = min_even_x;
+        phase_base_max_x = max_even_x;
+        phase_base_min_y = min_even_y;
+        phase_base_max_y = max_even_y;
+
+        if ((*best_x | *best_y) & 1)
+            try_motion_candidate(s, src, ref, halfpel,
+                                 x, y, width, height,
+                                 *best_x & ~1, *best_y & ~1,
+                                 min_even_x, max_even_x,
+                                 min_even_y, max_even_y,
+                                 pred_x, pred_y, lambda,
+                                 best_x, best_y, &best_cost);
+
+        saved_x = *best_x;
+        saved_y = *best_y;
+        saved_cost = best_cost;
+        /*
+         * Keep the rounded-even anchor unclipped and clamp only generated
+         * coarse/P candidates.  At an odd boundary, input Y -127 anchors at
+         * -128, so a +16 probe is -112 rather than -110.
+         */
+        full_x = *best_x & ~1;
+        full_y = *best_y & ~1;
+        full_cost = best_cost;
+        coarse_anchor_x = full_x;
+        coarse_anchor_y = full_y;
+        av_assert2(width >= 4 && height >= 4);
+        av_assert2(!coarse_bands || me_ref);
+        if (width == 4 && height == 8) {
+            /*
+             * Construct the 4x8 coarse proxy from source rows 0, 2, 4, and 6
+             * followed by four search-state words: the direct-motion marker,
+             * enabled ME method, incumbent motion cost, and reference number.
+             */
+            const uint32_t proxy_tail[4] = {
+                caller_best_motion_cost == MOBI_COST_INF ?
+                    (uint32_t)-y : 4,
+                1,
+                caller_best_motion_cost,
+                caller_reference,
+            };
+
+            for (int column = 0; column < 4; column++) {
+                int sum = 0;
+                int value;
+
+                for (int row = 0; row < 8; row += 2)
+                    sum += src->data[0][
+                        (y + row) * src->linesize[0] + x + column];
+                for (int row = 0; row < 4; row++)
+                    sum += (proxy_tail[row] >> (column * 8)) & UINT8_MAX;
+                value = (sum + carry) >> 3;
+                coarse_src[0][column] = value;
+                carry += sum - 8 * value;
+            }
+        } else if (width == 4 && height == 16) {
+            /*
+             * Construct the 4x16 coarse proxy in two passes.  The first uses
+             * even source rows; the second combines that result with the
+             * search-state marker, incumbent motion cost, and reference.
+             */
+            static const uint32_t proxy_state_marker = 1;
+            const uint32_t proxy_tail[3] = {
+                proxy_state_marker,
+                caller_best_motion_cost,
+                caller_reference,
+            };
+
+            for (int column = 0; column < 4; column++) {
+                int sum = 0;
+                int value;
+
+                for (int row = 0; row < 16; row += 2)
+                    sum += src->data[0][
+                        (y + row) * src->linesize[0] + x + column];
+                value = (sum + carry) >> 3;
+                coarse_src[0][column] = value;
+                carry += sum - 8 * value;
+            }
+            for (int column = 0; column < 4; column++) {
+                int sum = coarse_src[0][column];
+                int value;
+
+                for (int row = 8; row < 16; row += 2)
+                    sum += src->data[0][
+                        (y + row) * src->linesize[0] + x + column];
+                for (int row = 0; row < 3; row++)
+                    sum += (proxy_tail[row] >> (column * 8)) & UINT8_MAX;
+                value = (sum + carry) >> 3;
+                coarse_src[1][column] = value;
+                carry += sum - 8 * value;
+            }
+        } else {
+            for (int band = 0; band < coarse_bands; band++) {
+                for (int column = 0; column < width; column++) {
+                    int sum = 0;
+                    int value;
+
+                    for (int row = 0; row < 8; row++)
+                        sum += src->data[0][
+                            (y + band * 8 + row) * src->linesize[0] +
+                            x + column];
+                    value = (sum + carry) >> 3;
+                    coarse_src[band][column] = value;
+                    carry += sum - 8 * value;
+                }
+            }
+        }
+
+        for (int group = 0; group < 6; group++) {
+            int candidate[4][2];
+            int full_cost_x4[4];
+            int proxy[4];
+            int screen = 0;
+
+            for (int lane = 0; lane < 4; lane++) {
+                candidate[lane][0] = av_clip(
+                    coarse_anchor_x + initial_coarse[group][lane][0],
+                    min_even_x, max_even_x);
+                candidate[lane][1] = av_clip(
+                    coarse_anchor_y + initial_coarse[group][lane][1],
+                    min_even_y, max_even_y);
+                if (!coarse_bands) {
+                    screen = 1;
+                    continue;
+                }
+            }
+            if (coarse_bands) {
+                coarse_cost_x4(s, coarse_src, width, coarse_bands,
+                               me_ref, s->me_stride, x, y, candidate,
+                               pred_x, pred_y, lambda, proxy);
+                for (int lane = 0; lane < 4; lane++)
+                    /* Reject the batch only when every lane exceeds the bound. */
+                    screen |= proxy[lane] <= full_cost + screen_margin;
+            }
+            if (!screen)
+                continue;
+            motion_cost_x4(s, src, ref, halfpel, x, y, width, height,
+                           candidate, pred_x, pred_y, lambda, full_cost_x4);
+            for (int lane = 0; lane < 4; lane++) {
+                if (full_cost_x4[lane] >= full_cost)
+                    continue;
+                full_x = candidate[lane][0];
+                full_y = candidate[lane][1];
+                full_cost = full_cost_x4[lane];
+            }
+        }
+
+        /*
+         * Capture the P-pattern anchor once.  Scale winners update the best
+         * vector, but not this anchor.
+         */
+        p_x = full_x;
+        p_y = full_y;
+        {
+            for (int scale = 2; scale <= 8; scale += 2) {
+                int stage_cost = full_cost;
+
+                for (int group = 0; group < 4; group++) {
+                    int candidate[4][2];
+                    int full_cost_x4[4];
+                    int proxy[4];
+                    int screen = 0;
+
+                    for (int lane = 0; lane < 4; lane++) {
+                        const int index = group * 4 + lane;
+
+                        candidate[lane][0] = av_clip(
+                            p_x + scale * p_pattern[index][0],
+                            min_even_x, max_even_x);
+                        candidate[lane][1] = av_clip(
+                            p_y + scale * p_pattern[index][1],
+                            min_even_y, max_even_y);
+                        if (!coarse_bands) {
+                            screen = 1;
+                            continue;
+                        }
+                    }
+                    if (coarse_bands) {
+                        coarse_cost_x4(s, coarse_src, width, coarse_bands,
+                                       me_ref, s->me_stride, x, y, candidate,
+                                       pred_x, pred_y, lambda, proxy);
+                        for (int lane = 0; lane < 4; lane++)
+                            /* Reject only when every lane exceeds the bound. */
+                            screen |= proxy[lane] <=
+                                      full_cost + screen_margin;
+                    }
+                    if (!screen)
+                        continue;
+                    motion_cost_x4(s, src, ref, halfpel,
+                                   x, y, width, height, candidate,
+                                   pred_x, pred_y, lambda, full_cost_x4);
+                    for (int lane = 0; lane < 4; lane++) {
+                        if (full_cost_x4[lane] >= full_cost)
+                            continue;
+                        full_x = candidate[lane][0];
+                        full_y = candidate[lane][1];
+                        full_cost = full_cost_x4[lane];
+                    }
+                }
+                if (scale >= 4 && scale < 8 &&
+                    full_cost == stage_cost &&
+                    full_cost <= width * height - 1)
+                    break;
+            }
+        }
+
+        if (full_cost < saved_cost) {
+            *best_x = full_x;
+            *best_y = full_y;
+            best_cost = full_cost;
+        } else {
+            *best_x = saved_x;
+            *best_y = saved_y;
+            best_cost = saved_cost;
+        }
+    }
+    /* Hexagon descent: 5/0/1, then 2/3/4, then three forward points. */
+    {
+        int center_x = *best_x;
+        int center_y = *best_y;
+        /*
+         * Keep full-pel stages on the half-pel phase selected by the
+         * P-pattern winner.  Aligning both bounds to that phase prevents an
+         * edge clamp from changing parity.
+         */
+        int phase_min_x = phase_base_min_x +
+                          ((*best_x - phase_base_min_x) & 1);
+        int phase_max_x = phase_base_max_x -
+                          ((phase_base_max_x - *best_x) & 1);
+        int phase_min_y = phase_base_min_y +
+                          ((*best_y - phase_base_min_y) & 1);
+        int phase_max_y = phase_base_max_y -
+                          ((phase_base_max_y - *best_y) & 1);
+        int direction = -1;
+
+        for (int i = 0; i < (int)FF_ARRAY_ELEMS(initial_hex_order); i++) {
+            int candidate_direction = initial_hex_order[i];
+
+            if (try_motion_candidate(s, src, ref, halfpel,
+                                     x, y, width, height,
+                                     center_x + hex_pattern[candidate_direction][0],
+                                     center_y + hex_pattern[candidate_direction][1],
+                                     phase_min_x, phase_max_x,
+                                     phase_min_y, phase_max_y,
+                                     pred_x, pred_y, lambda,
+                                     best_x, best_y, &best_cost)) {
+                /*
+                 * Directions 5/0/1 form a three-lane batch.  Map its winner
+                 * with unsigned (lane - 1) % 6; lane zero becomes direction
+                 * 3 rather than 5.
+                 */
+                direction = initial_hex_direction[i];
+            }
+        }
+
+        while (direction >= 0) {
+            int next_direction = -1;
+            int winning_lane = -1;
+
+            center_x = *best_x;
+            center_y = *best_y;
+            for (int lane = -1; lane <= 1; lane++) {
+                int candidate_direction = (direction + lane + 6) % 6;
+
+                if (try_motion_candidate(s, src, ref, halfpel, x, y,
+                                         width, height,
+                                         center_x + hex_pattern[candidate_direction][0],
+                                         center_y + hex_pattern[candidate_direction][1],
+                                         phase_min_x, phase_max_x,
+                                         phase_min_y, phase_max_y,
+                                         pred_x, pred_y, lambda,
+                                         best_x, best_y, &best_cost))
+                    winning_lane = lane + 1;
+            }
+            /*
+             * Perform this modulo as unsigned.  Direction zero plus winning
+             * lane zero underflows to UINT_MAX and maps to direction three,
+             * rather than geometrically wrapping to direction five.
+             */
+            if (winning_lane >= 0)
+                next_direction =
+                    (uint32_t)(direction - 1 + winning_lane) % 6U;
+            direction = next_direction;
+        }
+        /* One full-pel eight-neighbor pass on the same selected phase. */
+        center_x = *best_x;
+        center_y = *best_y;
+
+        for (int i = 0; i < (int)FF_ARRAY_ELEMS(even_refine); i++)
+            try_motion_candidate(s, src, ref, halfpel,
+                                 x, y, width, height,
+                                 center_x + even_refine[i][0],
+                                 center_y + even_refine[i][1],
+                                 phase_min_x, phase_max_x,
+                                 phase_min_y, phase_max_y,
+                                 pred_x, pred_y, lambda,
+                                 best_x, best_y, &best_cost);
+
+        /* Non-realtime mode finishes with half-pel diamond descent. */
+        for (;;) {
+            center_x = *best_x;
+            center_y = *best_y;
+            int cost = best_cost;
+
+            for (int i = 0; i < (int)FF_ARRAY_ELEMS(half_refine); i++)
+                try_motion_candidate(s, src, ref, halfpel,
+                                     x, y, width, height,
+                                     center_x + half_refine[i][0],
+                                     center_y + half_refine[i][1],
+                                     phase_min_x, phase_max_x,
+                                     phase_min_y, phase_max_y,
+                                     pred_x, pred_y, lambda,
+                                     best_x, best_y, &best_cost);
+            if (best_cost == cost)
+                break;
+        }
+    }
+done:
+    *best_cost_out = best_cost;
+}
+
+static void search_motion(const MobiClipEncContext *s, const AVFrame *src,
+                          const AVFrame *ref,
+                          uint8_t *const halfpel[3],
+                          const uint8_t *me_ref,
+                          int x, int y,
+                          int pred_x, int pred_y,
+                          const int neighbors[3][2],
+                          int *best_x, int *best_y, int *best_cost_out)
+{
+    int candidates[4][2];
+
+    memcpy(candidates, neighbors, 3 * sizeof(*candidates));
+    candidates[3][0] = 0;
+    candidates[3][1] = 0;
+    search_motion_region(s, src, ref, halfpel, me_ref, x, y, 16, 16,
+                         1, pred_x, pred_y,
+                         candidates, FF_ARRAY_ELEMS(candidates),
+                         0, 0,
+                         best_x, best_y, best_cost_out);
+}
+
+static void motion_block(const AVFrame *ref, AVFrame *dst, int plane,
+                         int x, int y, int width, int height,
+                         int mv_x, int mv_y)
+{
+    int px = x, py = y;
+    int method;
+    const uint8_t *src;
+    uint8_t *out;
+    ptrdiff_t src_stride = ref->linesize[plane];
+    ptrdiff_t dst_stride = dst->linesize[plane];
+
+    if (plane) {
+        px >>= 1;
+        py >>= 1;
+        width >>= 1;
+        height >>= 1;
+        mv_x >>= 1;
+        mv_y >>= 1;
+    }
+
+    method = (mv_x & 1) | ((mv_y & 1) << 1);
+    src = ref->data[plane] + (py + (mv_y >> 1)) * src_stride +
+          px + (mv_x >> 1);
+    out = dst->data[plane] + py * dst_stride + px;
+
+    if (!method) {
+        av_image_copy_plane(out, dst_stride, src, src_stride, width, height);
+        return;
+    }
+
+    switch (method) {
+    case 1:
+        for (int j = 0; j < height; j++) {
+            for (int i = 0; i < width; i++)
+                out[i] = (src[i] >> 1) + (src[i + 1] >> 1);
+            src += src_stride;
+            out += dst_stride;
+        }
+        break;
+    case 2:
+        for (int j = 0; j < height; j++) {
+            for (int i = 0; i < width; i++)
+                out[i] = (src[i] >> 1) + (src[i + src_stride] >> 1);
+            src += src_stride;
+            out += dst_stride;
+        }
+        break;
+    default:
+        for (int j = 0; j < height; j++) {
+            for (int i = 0; i < width; i++)
+                out[i] = (((src[i] >> 1) + (src[i + 1] >> 1)) >> 1) +
+                         (((src[i + src_stride] >> 1) +
+                           (src[i + src_stride + 1] >> 1)) >> 1);
+            src += src_stride;
+            out += dst_stride;
+        }
+        break;
+    }
+}
+
+static int motion_is_valid(const AVFrame *ref, int x, int y,
+                           int width, int height, int mv_x, int mv_y)
+{
+    /*
+     * Represent a direct predictor in a signed eight-bit half-pel domain
+     * before intersecting it with image edges.  Explicit searches may leave
+     * this domain because they code a delta; direct symbols may not.
+     */
+    return mv_x >= INT8_MIN && mv_x <= INT8_MAX &&
+           mv_y >= INT8_MIN && mv_y <= INT8_MAX &&
+           x + (mv_x >> 1) >= 0 &&
+           y + (mv_y >> 1) >= 0 &&
+           x + width  + ((mv_x + 1) >> 1) <= ref->width &&
+           y + height + ((mv_y + 1) >> 1) <= ref->height;
+}
+
+static void save_macroblock(const AVFrame *frame, int x, int y,
+                            MobiEncMacroblockPixels *pixels)
+{
+    for (int plane = 0; plane < 3; plane++) {
+        int px = x >> !!plane;
+        int py = y >> !!plane;
+        int size = 16 >> !!plane;
+        uint8_t *dst = plane ? pixels->chroma[plane - 1] : pixels->luma;
+        const uint8_t *src = frame->data[plane] +
+                             py * frame->linesize[plane] + px;
+
+        for (int row = 0; row < size; row++)
+            memcpy(dst + row * size, src + row * frame->linesize[plane], size);
+    }
+}
+
+static void restore_macroblock(AVFrame *frame, int x, int y,
+                               const MobiEncMacroblockPixels *pixels)
+{
+    for (int plane = 0; plane < 3; plane++) {
+        int px = x >> !!plane;
+        int py = y >> !!plane;
+        int size = 16 >> !!plane;
+        const uint8_t *src = plane ? pixels->chroma[plane - 1] : pixels->luma;
+        uint8_t *dst = frame->data[plane] +
+                       py * frame->linesize[plane] + px;
+
+        for (int row = 0; row < size; row++)
+            memcpy(dst + row * frame->linesize[plane], src + row * size, size);
+    }
+}
+
+static uint64_t frame_sse(const AVCodecContext *avctx, const AVFrame *src,
+                          const AVFrame *recon, uint64_t plane_error[3])
+{
+    uint64_t error = 0;
+
+    for (int plane = 0; plane < 3; plane++) {
+        uint64_t component_error = 0;
+        int width  = plane ? avctx->width  >> 1 : avctx->width;
+        int height = plane ? avctx->height >> 1 : avctx->height;
+
+        for (int y = 0; y < height; y++) {
+            const uint8_t *a = src->data[plane] + y * src->linesize[plane];
+            const uint8_t *b = recon->data[plane] + y * recon->linesize[plane];
+
+            for (int x = 0; x < width; x++) {
+                int delta = a[x] - b[x];
+
+                component_error += delta * delta;
+            }
+        }
+        if (plane_error)
+            plane_error[plane] = component_error;
+        error += component_error;
+    }
+    return error;
+}
+
 static void setup_qtable(MobiClipEncContext *s)
 {
     for (int i = 0; i < 16; i++) {
@@ -1597,9 +2694,904 @@ static int encode_intra(AVCodecContext *avctx, PutBitContext *pb,
     return 0;
 }
 
+static int prepare_inter_residual(MobiClipEncContext *s,
+                                  const AVFrame *src, int x, int y,
+                                  MobiEncMacroblock *candidate)
+{
+    int cost = 0;
+    int index;
+
+    candidate->component_mask = 0;
+    cost += prepare_inter_unit(s, src, 0, x,     y,
+                               &candidate->unit[0]);
+    cost += prepare_inter_unit(s, src, 0, x + 8, y,
+                               &candidate->unit[1]);
+    cost += prepare_inter_unit(s, src, 0, x,     y + 8,
+                               &candidate->unit[2]);
+    cost += prepare_inter_unit(s, src, 0, x + 8, y + 8,
+                               &candidate->unit[3]);
+    cost += prepare_inter_unit(s, src, 1 + !s->moflex,
+                               x >> 1, y >> 1, &candidate->unit[4]);
+    cost += prepare_inter_unit(s, src, 2 - !s->moflex,
+                               x >> 1, y >> 1, &candidate->unit[5]);
+    for (int i = 0; i < MOBI_INTER_UNITS; i++)
+        if (candidate->unit[i].mask)
+            candidate->component_mask |= 1U << i;
+    index = find_mask(ff_mobiclip_p8_masks,
+                      FF_ARRAY_ELEMS(ff_mobiclip_p8_masks),
+                      candidate->component_mask);
+    av_assert2(index >= 0);
+    return cost + mobi_ue_bits(index) * s->lambda;
+}
+
+static int prepare_inter_macroblock(MobiClipEncContext *s,
+                                    const AVFrame *src, const AVFrame *ref,
+                                    int x, int y,
+                                    int mv_x, int mv_y,
+                                    MobiEncMacroblock *candidate)
+{
+    candidate->mv_x = mv_x;
+    candidate->mv_y = mv_y;
+    candidate->partition_count = 0;
+
+    for (int plane = 0; plane < 3; plane++)
+        motion_block(ref, s->recon, plane, x, y, 16, 16,
+                     mv_x, mv_y);
+
+    return prepare_inter_residual(s, src, x, y, candidate);
+}
+
+static void put_inter_mode(const MobiClipEncContext *s, PutBitContext *pb,
+                           int mode)
+{
+    av_assert2(mode >= 0 && mode <= 9);
+    put_bits(pb, mobi_partition_len[s->moflex][0][mode],
+             mobi_partition_code[s->moflex][0][mode]);
+}
+
+static void put_partition_tree(const MobiClipEncContext *s,
+                               PutBitContext *pb,
+                               const MobiEncMacroblock *candidate,
+                               int index, int pred_x, int pred_y)
+{
+    const MobiEncPartition *node = &candidate->partition[index];
+    int partition_class =
+        mobi_partition_class(node->width, node->height);
+    int length =
+        mobi_partition_len[s->moflex][partition_class][node->mode];
+
+    av_assert2(length);
+    put_bits(pb, length,
+             mobi_partition_code[s->moflex][partition_class][node->mode]);
+    if (node->mode == 8 || node->mode == 9) {
+        put_partition_tree(s, pb, candidate, node->child[0],
+                           pred_x, pred_y);
+        put_partition_tree(s, pb, candidate, node->child[1],
+                           pred_x, pred_y);
+    } else if (node->mode > 0) {
+        set_se_golomb(pb, node->mv_x - pred_x);
+        set_se_golomb(pb, node->mv_y - pred_y);
+    }
+}
+
+static void put_inter_residual(const MobiClipEncContext *s,
+                               PutBitContext *pb,
+                               const MobiEncMacroblock *candidate)
+{
+    set_ue_golomb(pb, find_mask(ff_mobiclip_p8_masks,
+                                FF_ARRAY_ELEMS(ff_mobiclip_p8_masks),
+                                candidate->component_mask));
+    for (int i = 0; i < MOBI_INTER_UNITS; i++)
+        if (candidate->component_mask & (1U << i))
+            put_inter_unit(s, pb, &candidate->unit[i]);
+}
+
+static void put_inter_macroblock(const MobiClipEncContext *s,
+                                 PutBitContext *pb,
+                                 const MobiEncMacroblock *candidate,
+                                 const MobiIntraMacroblock *intra,
+                                 int pred_x, int pred_y,
+                                 int *motion_bits)
+{
+    if (candidate->mode == 6 || candidate->mode == 7) {
+        put_inter_mode(s, pb, candidate->mode);
+        av_assert2(intra && intra->predicted == (candidate->mode == 7));
+        put_intra_macroblock_payload(s, pb, intra, 0);
+        return;
+    }
+    if (candidate->partition_count) {
+        put_partition_tree(s, pb, candidate, 0, pred_x, pred_y);
+    } else {
+        put_inter_mode(s, pb, candidate->mode);
+    }
+    if (!candidate->partition_count && candidate->mode > 0) {
+        set_se_golomb(pb, candidate->mv_x - pred_x);
+        set_se_golomb(pb, candidate->mv_y - pred_y);
+    }
+    if (motion_bits)
+        *motion_bits = put_bits_count(pb);
+    put_inter_residual(s, pb, candidate);
+}
+
+static int inter_macroblock_bits(const MobiClipEncContext *s,
+                                 MobiEncMacroblock *candidate,
+                                 int pred_x, int pred_y)
+{
+    uint8_t buffer[MOBI_RDO_BUFFER_SIZE];
+    PutBitContext pb;
+
+    init_put_bits(&pb, buffer, sizeof(buffer));
+    put_inter_macroblock(s, &pb, candidate, NULL, pred_x, pred_y,
+                         &candidate->motion_bits);
+    return put_bits_count(&pb);
+}
+
+static int inter_macroblock_rdo_cost(const MobiClipEncContext *s,
+                                     const MobiEncMacroblock *candidate,
+                                     int residual_rdo_cost)
+{
+    /*
+     * Keep accumulated per-unit residual costs separate from mode and
+     * motion-tree syntax costs.  Recomputing the residual term from final
+     * whole-macroblock SSE loses per-unit integer rounding at close
+     * decisions.
+    */
+    return residual_rdo_cost +
+           candidate->motion_bits * s->lambda;
+}
+
+static int evaluate_dc_intra_probe_cost(MobiClipEncContext *s,
+                                        const AVFrame *src, int x, int y)
+{
+    int plane[2] = { 1 + !s->moflex, 2 - !s->moflex };
+    int lambda = s->lambda;
+    int cost =
+        (mobi_partition_len[s->moflex][0][6] + 3) * lambda;
+
+    /*
+     * This inexpensive fixed-DC intra probe predicts each 8x8 region in
+     * raster order and invokes only the stride-aware 8x8 residual kernel.  It
+     * does not evaluate four-4x4 residuals or charge a combined component
+     * mask.
+     */
+    for (int by = 0; by < 16; by += 8) {
+        for (int bx = 0; bx < 16; bx += 8) {
+            const uint8_t *input =
+                src->data[0] + (y + by) * src->linesize[0] + x + bx;
+            const uint8_t *prediction;
+            uint8_t reconstructed[64];
+            MobiEncBlock block;
+
+            intra_predict_block(&s->intra_pred, s->recon, 0,
+                                x + bx, y + by, 8, 3, 0);
+            prediction = s->recon->data[0] +
+                         (y + by) * s->recon->linesize[0] + x + bx;
+            cost += evaluate_residual8x8(
+                s, input, src->linesize[0],
+                prediction, s->recon->linesize[0],
+                reconstructed, &block, 0);
+            if (block.has_coeffs)
+                cost += lambda;
+            /*
+             * The probe writes only coefficient and inverse-residual state.
+             * Its DC prediction remains in the working plane, so the next
+             * raster block predicts from that raw DC block.
+             */
+        }
+    }
+    for (int p = 0; p < 2; p++) {
+        int cx = x >> 1;
+        int cy = y >> 1;
+        const uint8_t *input =
+            src->data[plane[p]] + cy * src->linesize[plane[p]] + cx;
+        const uint8_t *prediction;
+        uint8_t reconstructed[64];
+        MobiEncBlock block;
+
+        intra_predict_block(&s->intra_pred, s->recon, plane[p],
+                            cx, cy, 8, 3, 0);
+        prediction = s->recon->data[plane[p]] +
+                     cy * s->recon->linesize[plane[p]] + cx;
+        cost += evaluate_residual8x8(
+            s, input, src->linesize[plane[p]],
+            prediction, s->recon->linesize[plane[p]],
+            reconstructed, &block, 0);
+        if (block.has_coeffs)
+            cost += lambda;
+    }
+    return cost;
+}
+
+static AVFrame *mobiclip_reference(const MobiClipEncContext *s, int age)
+{
+    av_assert2(age >= 1 && age <= MOBI_MAX_REFERENCES);
+    return s->reference[(s->current_reference - age +
+                         MOBI_REFERENCE_SLOTS) % MOBI_REFERENCE_SLOTS];
+}
+
+static const uint8_t *mobiclip_me_reference(const MobiClipEncContext *s,
+                                             int age)
+{
+    av_assert2(age >= 1 && age <= MOBI_MAX_REFERENCES);
+    return s->me_reference[(s->current_reference - age +
+                             MOBI_REFERENCE_SLOTS) % MOBI_REFERENCE_SLOTS];
+}
+
+static uint8_t *const *mobiclip_halfpel_reference(
+    const MobiClipEncContext *s, int age)
+{
+    av_assert2(age >= 1 && age <= MOBI_MAX_REFERENCES);
+    return s->halfpel_reference[(s->current_reference - age +
+                                 MOBI_REFERENCE_SLOTS) %
+                                MOBI_REFERENCE_SLOTS];
+}
+
+static int partition_plane_sse(const AVFrame *src,
+                               const AVFrame *recon, int plane,
+                               int x, int y, int width, int height)
+{
+    const uint8_t *a = src->data[plane] +
+                       y * src->linesize[plane] + x;
+    const uint8_t *b = recon->data[plane] +
+                       y * recon->linesize[plane] + x;
+    int error = 0;
+
+    for (int row = 0; row < height; row++) {
+        for (int column = 0; column < width; column++) {
+            int delta = a[column] - b[column];
+
+            error += delta * delta;
+        }
+        a += src->linesize[plane];
+        b += recon->linesize[plane];
+    }
+    return error;
+}
+
+/*
+ * Partition evaluators tile their region into independently coded residual
+ * units.  Complete 8x8 regions run normal inter-unit RDO.  Regions that are
+ * at least 4x4 but cannot contain an 8x8 unit evaluate each covered 4x4
+ * transform independently.  The resulting masks describe scratch residuals
+ * but do not charge a p4 selector: the final macroblock residual is rebuilt
+ * after selecting the partition tree.  Narrower regions contribute
+ * prediction SSE only.
+ */
+static int partition_partial_unit_cost(const MobiClipEncContext *s,
+                                       const AVFrame *src, int plane,
+                                       int x, int y,
+                                       int width, int height)
+{
+    int cost = 0;
+    MobiEncBlock block;
+
+    for (int by = y; by < y + height; by += 4) {
+        for (int bx = x; bx < x + width; bx += 4) {
+            uint8_t recon[16];
+            const uint8_t *input =
+                src->data[plane] + by * src->linesize[plane] + bx;
+            const uint8_t *prediction =
+                s->recon->data[plane] +
+                by * s->recon->linesize[plane] + bx;
+
+            cost += evaluate_residual4x4(
+                s, input, src->linesize[plane],
+                prediction, s->recon->linesize[plane],
+                recon, 4, &block, 0);
+        }
+    }
+    return cost;
+}
+
+static int partition_leaf_residual_cost(MobiClipEncContext *s,
+                                        const AVFrame *src,
+                                        int x, int y,
+                                        int width, int height,
+                                        int bound)
+{
+    int cost = 0;
+
+    for (int plane = 0; plane < 3; plane++) {
+        int shift = !!plane;
+        int px = x >> shift;
+        int py = y >> shift;
+        int pw = width >> shift;
+        int ph = height >> shift;
+
+        if (pw >= 8 && ph >= 8) {
+            for (int by = py; by < py + ph; by += 8) {
+                for (int bx = px; bx < px + pw; bx += 8) {
+                    cost += evaluate_inter_unit_cost(
+                        s, s->recon, src, plane, bx, by);
+                    if (cost >= bound)
+                        return MOBI_COST_INF;
+                }
+            }
+        } else if (pw >= 4 && ph >= 4) {
+            cost += partition_partial_unit_cost(s, src, plane, px, py,
+                                                pw, ph);
+            if (cost >= bound)
+                return MOBI_COST_INF;
+        } else {
+            cost += partition_plane_sse(src, s->recon, plane,
+                                        px, py, pw, ph);
+            if (cost >= bound)
+                return MOBI_COST_INF;
+        }
+    }
+    return cost;
+}
+
+static void reconstruct_partition_leaf(MobiClipEncContext *s,
+                                       const MobiEncPartition *leaf)
+{
+    const AVFrame *ref =
+        mobiclip_reference(s, leaf->mode ? leaf->mode : 1);
+
+    for (int plane = 0; plane < 3; plane++)
+        motion_block(ref, s->recon, plane, leaf->x, leaf->y,
+                     leaf->width, leaf->height, leaf->mv_x, leaf->mv_y);
+}
+
+static void reconstruct_partition_tree(MobiClipEncContext *s,
+                                       const MobiEncMacroblock *mb,
+                                       int index)
+{
+    const MobiEncPartition *node = &mb->partition[index];
+
+    if (node->mode == 8 || node->mode == 9) {
+        reconstruct_partition_tree(s, mb, node->child[0]);
+        reconstruct_partition_tree(s, mb, node->child[1]);
+    } else {
+        reconstruct_partition_leaf(s, node);
+    }
+}
+
+static int evaluate_partition_leaf(MobiClipEncContext *s,
+                                   const AVFrame *src,
+                                   int x, int y, int width, int height,
+                                   int pred_x, int pred_y,
+                                   MobiPartitionSeed
+                                       seed[16][MOBI_PARTITION_SEED_POSITIONS],
+                                   const int candidates[][2],
+                                   int candidate_count,
+                                   int available_refs, int fixed_ref,
+                                   int bound,
+                                   MobiEncPartition *result)
+{
+    int lambda = s->lambda;
+    int best_cost = bound;
+    int partition_class = mobi_partition_class(width, height);
+    int current_motion_cost = MOBI_COST_INF;
+    int first_ref = fixed_ref >= 0 ? fixed_ref + 1 : 1;
+    int last_ref = fixed_ref >= 0 ? fixed_ref + 1 : available_refs;
+    MobiPartitionSeed *slot =
+        mobi_partition_seed(seed, x, y, width, height);
+
+    memset(result, 0, sizeof(*result));
+    result->x = x;
+    result->y = y;
+    result->width = width;
+    result->height = height;
+    result->seed_x = slot->x;
+    result->seed_y = slot->y;
+    result->child[0] = result->child[1] = -1;
+
+    if (motion_is_valid(mobiclip_reference(s, 1), x, y,
+                        width, height, pred_x, pred_y)) {
+        int residual_cost;
+        int syntax_cost =
+            mobi_partition_len[s->moflex][partition_class][0] * lambda;
+
+        result->mode = 0;
+        result->mv_x = pred_x;
+        result->mv_y = pred_y;
+        if (syntax_cost < bound) {
+            reconstruct_partition_leaf(s, result);
+            residual_cost = partition_leaf_residual_cost(
+                s, src, x, y, width, height, bound - syntax_cost);
+            if (residual_cost != MOBI_COST_INF)
+                best_cost = residual_cost + syntax_cost;
+        }
+        current_motion_cost =
+            block_sad_halfpel(s, src, mobiclip_reference(s, 1),
+                              mobiclip_halfpel_reference(s, 1), x, y,
+                              width, height, pred_x, pred_y);
+    }
+
+    for (int ref = first_ref; ref <= last_ref; ref++) {
+        MobiEncPartition candidate = *result;
+        int cost;
+        int residual_cost;
+        int syntax_cost;
+        int motion_cost;
+        int sqrt_lambda = s->motion_lambda;
+
+        if ((int64_t)sqrt_lambda * 2 >= current_motion_cost)
+            break;
+        candidate.mode = ref;
+        search_motion_region(s, src, mobiclip_reference(s, ref),
+                             mobiclip_halfpel_reference(s, ref),
+                             mobiclip_me_reference(s, ref),
+                             x, y, width, height,
+                             width >= 4 && height >= 4,
+                             pred_x, pred_y,
+                             candidates, candidate_count,
+                             current_motion_cost, ref,
+                             &candidate.mv_x, &candidate.mv_y,
+                             &motion_cost);
+        if ((int64_t)motion_cost * 2 >=
+            (int64_t)current_motion_cost * 3)
+            continue;
+        syntax_cost = (
+            mobi_partition_len[s->moflex][partition_class][ref] +
+            motion_vector_bits(candidate.mv_x - pred_x) +
+            motion_vector_bits(candidate.mv_y - pred_y)) * lambda;
+        if (syntax_cost >= FFMIN(best_cost, bound))
+            continue;
+        reconstruct_partition_leaf(s, &candidate);
+        residual_cost = partition_leaf_residual_cost(
+            s, src, x, y, width, height,
+            FFMIN(best_cost, bound) - syntax_cost);
+        if (residual_cost == MOBI_COST_INF)
+            continue;
+        cost = residual_cost + syntax_cost;
+        if (cost < best_cost) {
+            candidate.seed_x = candidate.mv_x - pred_x;
+            candidate.seed_y = candidate.mv_y - pred_y;
+            *result = candidate;
+            best_cost = cost;
+            current_motion_cost = motion_cost;
+            slot->x = candidate.seed_x;
+            slot->y = candidate.seed_y;
+        }
+    }
+
+    result->local_cost = best_cost;
+    return best_cost < bound;
+}
+
+static int try_partition_leaf_split(MobiClipEncContext *s,
+                                    const AVFrame *src,
+                                    MobiEncMacroblock *mb, int index,
+                                    int split_mode,
+                                    int pred_x, int pred_y,
+                                    MobiPartitionSeed
+                                        seed[16][MOBI_PARTITION_SEED_POSITIONS],
+                                    int available_refs, int fixed_ref)
+{
+    MobiEncPartition *node = &mb->partition[index];
+    {
+        int partition_class =
+            mobi_partition_class(node->width, node->height);
+        int syntax_bits =
+            mobi_partition_len[s->moflex][partition_class][split_mode];
+        int syntax_cost = syntax_bits * s->lambda;
+        int remaining;
+        MobiEncPartition child[2];
+        int child_index = node->mode == 8 || node->mode == 9 ?
+                          node->child[0] : mb->partition_count;
+        int reuse_children = child_index >= 0 &&
+                             node->child[1] == child_index + 1;
+        int child_width = node->width;
+        int child_height = node->height;
+        int second_x = node->x;
+        int second_y = node->y;
+        /*
+         * Fixed partition records use their stored delta as the sole child
+         * search endpoint.  A direct result does not overwrite those fields.
+         * Snapshotting them matters because later sibling or orientation
+         * probes may update the persistent seed.
+         */
+        int candidates[1][2] = {
+            { node->seed_x, node->seed_y }
+        };
+
+        if (!syntax_bits || node->local_cost <= syntax_cost ||
+            (!reuse_children &&
+             child_index + 1 >= MOBI_MAX_PARTITION_NODES))
+            return 0;
+
+        if (split_mode == 8) {
+            child_height >>= 1;
+            second_y += child_height;
+        } else {
+            child_width >>= 1;
+            second_x += child_width;
+        }
+        remaining = node->local_cost - syntax_cost;
+        if (!evaluate_partition_leaf(
+                s, src, node->x, node->y, child_width, child_height,
+                pred_x, pred_y, seed,
+                candidates, FF_ARRAY_ELEMS(candidates),
+                available_refs, fixed_ref,
+                remaining, child))
+            return 0;
+        remaining -= child[0].local_cost;
+        if (!evaluate_partition_leaf(
+                s, src, second_x, second_y, child_width, child_height,
+                pred_x, pred_y, seed,
+                candidates, FF_ARRAY_ELEMS(candidates),
+                available_refs, fixed_ref,
+                remaining, &child[1]))
+            return 0;
+        mb->partition[child_index] = child[0];
+        mb->partition[child_index + 1] = child[1];
+        if (!reuse_children)
+            mb->partition_count += 2;
+        node = &mb->partition[index];
+        node->mode = split_mode;
+        node->child[0] = child_index;
+        node->child[1] = child_index + 1;
+        node->local_cost = syntax_cost +
+                           child[0].local_cost + child[1].local_cost;
+        return 1;
+    }
+}
+
+/*
+ * At the requested depth, try both split orientations for every reachable
+ * leaf.  This permits mixed trees, such as a horizontal child below a
+ * vertical root.
+ */
+static int try_partition_descendants_at_depth(MobiClipEncContext *s,
+                                              const AVFrame *src,
+                                              MobiEncMacroblock *mb, int index,
+                                              int depth, int target_depth,
+                                              int pred_x, int pred_y,
+                                              MobiPartitionSeed
+                                                  seed[16][MOBI_PARTITION_SEED_POSITIONS],
+                                              int available_refs,
+                                              int fixed_ref)
+{
+    MobiEncPartition *node = &mb->partition[index];
+    int changed = 0;
+
+    if (depth == target_depth) {
+        changed |= try_partition_leaf_split(
+            s, src, mb, index, 8, pred_x, pred_y,
+            seed,
+            available_refs, fixed_ref);
+        changed |= try_partition_leaf_split(
+            s, src, mb, index, 9, pred_x, pred_y,
+            seed,
+            available_refs, fixed_ref);
+        return changed;
+    }
+    if (node->mode != 8 && node->mode != 9)
+        return 0;
+
+    changed |= try_partition_descendants_at_depth(
+        s, src, mb, node->child[0], depth + 1, target_depth,
+        pred_x, pred_y, seed, available_refs, fixed_ref);
+    changed |= try_partition_descendants_at_depth(
+        s, src, mb, node->child[1], depth + 1, target_depth,
+        pred_x, pred_y, seed, available_refs, fixed_ref);
+    return changed;
+}
+
+static int try_partition_root_path(MobiClipEncContext *s,
+                                   const AVFrame *src,
+                                   MobiEncMacroblock *mb, int root_mode,
+                                   int target_depth,
+                                   int pred_x, int pred_y,
+                                   MobiPartitionSeed
+                                       seed[16][MOBI_PARTITION_SEED_POSITIONS],
+                                   int available_refs, int fixed_ref)
+{
+    if (!target_depth)
+        return try_partition_leaf_split(
+            s, src, mb, 0, root_mode, pred_x, pred_y,
+            seed,
+            available_refs, fixed_ref);
+    if (mb->partition[0].mode != root_mode)
+        return 0;
+
+    return try_partition_descendants_at_depth(
+        s, src, mb, 0, 0, target_depth,
+        pred_x, pred_y, seed, available_refs, fixed_ref);
+}
+
+static void partition_last_motion(const MobiEncMacroblock *mb, int index,
+                                  int *mv_x, int *mv_y)
+{
+    const MobiEncPartition *node = &mb->partition[index];
+
+    if (node->mode == 8 || node->mode == 9) {
+        partition_last_motion(mb, node->child[1], mv_x, mv_y);
+    } else {
+        *mv_x = node->mv_x;
+        *mv_y = node->mv_y;
+    }
+}
+
+static int evaluate_partition_tree(MobiClipEncContext *s,
+                                   const AVFrame *src, int x, int y,
+                                   int pred_x, int pred_y,
+                                   MobiEncMacroblock *candidate,
+                                   MobiEncMacroblockPixels *pixels)
+{
+    int residual_rdo_cost;
+
+    reconstruct_partition_tree(s, candidate, 0);
+    residual_rdo_cost = prepare_inter_residual(s, src, x, y, candidate);
+    candidate->bits = inter_macroblock_bits(s, candidate, pred_x, pred_y);
+    save_macroblock(s->recon, x, y, pixels);
+    return inter_macroblock_rdo_cost(s, candidate, residual_rdo_cost);
+}
+
+static int refine_inter_partitions(MobiClipEncContext *s,
+                                   const AVFrame *src, int x, int y,
+                                   int pred_x, int pred_y,
+                                   MobiPartitionSeed
+                                       seed[16][MOBI_PARTITION_SEED_POSITIONS],
+                                   int available_refs,
+                                   int best_inter_cost,
+                                   int selected_cost,
+                                   MobiEncMacroblock *inter,
+                                   MobiEncMacroblockPixels *inter_pixels)
+{
+    MobiEncMacroblock working = *inter;
+    MobiEncMacroblock best;
+    MobiEncMacroblockPixels best_pixels = *inter_pixels;
+    int fixed_ref = s->mixed_refs ? -1 :
+                    (working.mode ? working.mode - 1 : 0);
+
+    if (working.mode < 0 || working.mode > MOBI_MAX_REFERENCES ||
+        best_inter_cost * 4 >= selected_cost * 5)
+        return 0;
+
+    working.partition_count = 1;
+    {
+        MobiPartitionSeed *root_seed =
+            mobi_partition_seed(seed, x, y, 16, 16);
+
+    working.partition[0] = (MobiEncPartition) {
+        .x = x, .y = y, .width = 16, .height = 16,
+        .mode = working.mode, .mv_x = working.mv_x, .mv_y = working.mv_y,
+        .seed_x = root_seed->x, .seed_y = root_seed->y,
+        .child = { -1, -1 },
+    };
+    }
+    /*
+     * Keep the selected full-macroblock cost as the outer comparison bound,
+     * but reset the root's local bound before trying the first split.  Child
+     * pruning remains local to the candidate tree.
+     */
+    working.partition[0].local_cost = MOBI_COST_INF;
+    best = working;
+
+    /*
+     * Keep the accepted tree separate, but run both orientation attempts on
+     * the same working tree.  A rejected horizontal candidate still supplies
+     * the local bound and topology seen by the vertical attempt.
+     */
+    for (int depth = 0; ; depth++) {
+        int accepted_horizontal = 0;
+        int accepted_vertical = 0;
+        MobiEncMacroblockPixels candidate_pixels;
+
+        if (try_partition_root_path(
+                s, src, &working, 8, depth,
+                pred_x, pred_y, seed, available_refs, fixed_ref)) {
+            int cost = evaluate_partition_tree(
+                s, src, x, y, pred_x, pred_y,
+                &working, &candidate_pixels);
+
+            if (cost < selected_cost) {
+                best = working;
+                best_pixels = candidate_pixels;
+                selected_cost = cost;
+                accepted_horizontal = 1;
+            }
+        }
+
+        if (try_partition_root_path(
+                s, src, &working, 9, depth,
+                pred_x, pred_y, seed, available_refs, fixed_ref)) {
+            int cost = evaluate_partition_tree(
+                s, src, x, y, pred_x, pred_y,
+                &working, &candidate_pixels);
+
+            if (cost < selected_cost) {
+                best = working;
+                best_pixels = candidate_pixels;
+                selected_cost = cost;
+                accepted_vertical = 1;
+            }
+        }
+
+        if (!accepted_horizontal && !accepted_vertical)
+            break;
+    }
+
+    if (best.partition[0].mode != inter->mode) {
+        best.mode = best.partition[0].mode;
+        partition_last_motion(&best, 0, &best.mv_x, &best.mv_y);
+        *inter = best;
+        *inter_pixels = best_pixels;
+        restore_macroblock(s->recon, x, y, &best_pixels);
+        return 1;
+    }
+    return 0;
+}
+
+static int encode_inter(AVCodecContext *avctx, PutBitContext *pb,
+                        const AVFrame *src)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+    int mb_width = avctx->width / 16;
+    int available_refs = FFMIN(avctx->refs, s->frames_since_key);
+
+    put_bits(pb, 1, 0);
+    set_se_golomb(pb, s->qp - s->previous_qp);
+    memset(s->motion, 0, (mb_width + 3) * sizeof(*s->motion));
+    memset(s->intra_context, 9, sizeof(s->intra_context));
+
+    for (int y = 0; y < avctx->height; y += 16) {
+        for (int x = 0; x < avctx->width; x += 16) {
+            MobiEncMacroblock candidate;
+            MobiEncMacroblock selected;
+            MobiEncMacroblock selected_inter;
+            MobiIntraMacroblock selected_intra;
+            MobiEncMacroblockPixels selected_pixels;
+            MobiEncMacroblockPixels inter_pixels;
+            uint8_t intra_context_start[20];
+            int neighbors[3][2];
+            int mb_x = x / 16;
+            int pred_x = median3(s->motion[mb_x + 1].x,
+                                 s->motion[mb_x + 2].x,
+                                 s->motion[mb_x + 3].x);
+            int pred_y = median3(s->motion[mb_x + 1].y,
+                                 s->motion[mb_x + 2].y,
+                                 s->motion[mb_x + 3].y);
+            int current_motion_cost = INT_MAX;
+            int selected_cost = MOBI_COST_INF;
+            int best_inter_cost;
+            int dc_probe_cost;
+
+            for (int i = 0; i < 3; i++) {
+                neighbors[i][0] = s->motion[mb_x + i + 1].x;
+                neighbors[i][1] = s->motion[mb_x + i + 1].y;
+            }
+            s->motion[mb_x + 2].x = 0;
+            s->motion[mb_x + 2].y = 0;
+
+            /*
+             * Root symbol 0 uses the newest reconstruction plus the common
+             * spatial predictor.  Evaluate it before explicit-reference
+             * symbols so strict comparisons preserve it on equal cost.
+             */
+            if (motion_is_valid(mobiclip_reference(s, 1),
+                                x, y, 16, 16, pred_x, pred_y)) {
+                int residual_rdo_cost;
+
+                candidate.mode = 0;
+                residual_rdo_cost =
+                    prepare_inter_macroblock(
+                        s, src, mobiclip_reference(s, 1), x, y,
+                        pred_x, pred_y, &candidate);
+                candidate.bits = inter_macroblock_bits(s, &candidate,
+                                                       pred_x, pred_y);
+                selected = candidate;
+                selected_cost = inter_macroblock_rdo_cost(
+                    s, &candidate, residual_rdo_cost);
+                current_motion_cost =
+                    block_sad_halfpel(s, src, mobiclip_reference(s, 1),
+                                      mobiclip_halfpel_reference(s, 1), x, y,
+                                      16, 16, pred_x, pred_y);
+                save_macroblock(s->recon, x, y, &selected_pixels);
+            }
+
+            /*
+             * Explicit symbols 1..5 select newest..oldest references.  Stop
+             * once two zero-delta syntax terms dominate the current motion
+             * cost, and run residual RDO only below 1.5 times that cost.
+             */
+            for (int ref = 1; ref <= available_refs; ref++) {
+                int motion_cost;
+                int residual_rdo_cost;
+                int cost;
+                int sqrt_lambda = s->motion_lambda;
+
+                if ((int64_t)sqrt_lambda * 2 >= current_motion_cost)
+                    break;
+                candidate.mode = ref;
+                search_motion(s, src, mobiclip_reference(s, ref),
+                              mobiclip_halfpel_reference(s, ref),
+                              mobiclip_me_reference(s, ref), x, y,
+                              pred_x, pred_y, neighbors,
+                              &candidate.mv_x, &candidate.mv_y, &motion_cost);
+                if ((int64_t)motion_cost * 2 >=
+                    (int64_t)current_motion_cost * 3)
+                    continue;
+                residual_rdo_cost =
+                    prepare_inter_macroblock(
+                        s, src, mobiclip_reference(s, ref), x, y,
+                        candidate.mv_x, candidate.mv_y, &candidate);
+                candidate.bits = inter_macroblock_bits(s, &candidate,
+                                                       pred_x, pred_y);
+                cost = inter_macroblock_rdo_cost(
+                    s, &candidate, residual_rdo_cost);
+                if (cost < selected_cost) {
+                    MobiPartitionSeed *root_seed =
+                        mobi_partition_seed(s->partition_seed,
+                                            x, y, 16, 16);
+
+                    selected = candidate;
+                    selected_cost = cost;
+                    current_motion_cost = motion_cost;
+                    /*
+                     * Update the persistent root seed only when an explicit
+                     * candidate wins.  Direct mode leaves it untouched, so
+                     * partition search reuses the preceding winning delta.
+                     */
+                    root_seed->x = candidate.mv_x - pred_x;
+                    root_seed->y = candidate.mv_y - pred_y;
+                    save_macroblock(s->recon, x, y, &selected_pixels);
+                }
+            }
+
+            av_assert2(selected_cost != MOBI_COST_INF);
+            restore_macroblock(s->recon, x, y, &selected_pixels);
+            selected_inter = selected;
+            inter_pixels = selected_pixels;
+            best_inter_cost = selected_cost;
+            memcpy(intra_context_start, s->intra_context,
+                   sizeof(intra_context_start));
+            dc_probe_cost = evaluate_dc_intra_probe_cost(s, src, x, y);
+
+            /*
+             * Evaluate an inexpensive fixed-DC 8x8-only intra probe before
+             * running the complete modes 6/7 search.
+             */
+            if (dc_probe_cost * 2 < best_inter_cost * 3) {
+                /*
+                 * Enter complete intra search with fixed-DC probe predictions
+                 * still in the working planes.  Restore the selected inter
+                 * reconstruction only after choosing the final mode.
+                 */
+                if (evaluate_intra_macroblock(s, src, x, y, 0,
+                                              selected_cost,
+                                              &selected_intra)) {
+                    selected.mode = selected_intra.predicted ? 7 : 6;
+                    selected.mv_x = selected.mv_y = 0;
+                    selected_cost = selected_intra.cost;
+                } else {
+                    restore_macroblock(s->recon, x, y, &selected_pixels);
+                }
+            }
+
+            if (refine_inter_partitions(
+                    s, src, x, y, pred_x, pred_y,
+                    s->partition_seed,
+                    available_refs, best_inter_cost, selected_cost,
+                    &selected_inter, &inter_pixels)) {
+                selected = selected_inter;
+                selected_pixels = inter_pixels;
+                memcpy(s->intra_context, intra_context_start,
+                       sizeof(s->intra_context));
+            } else if (selected.mode == 6 || selected.mode == 7) {
+                commit_intra_macroblock(s, x, y, &selected_intra);
+            } else {
+                restore_macroblock(s->recon, x, y, &selected_pixels);
+            }
+
+            s->motion[mb_x + 2].x = selected.mv_x;
+            s->motion[mb_x + 2].y = selected.mv_y;
+            put_inter_macroblock(s, pb, &selected,
+                                 selected.mode == 6 || selected.mode == 7 ?
+                                 &selected_intra : NULL,
+                                 pred_x, pred_y, NULL);
+        }
+    }
+    return 0;
+}
+
 static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
 {
     MobiClipEncContext *s = avctx->priv_data;
+    int pass_flags = avctx->flags & (AV_CODEC_FLAG_PASS1 | AV_CODEC_FLAG_PASS2);
     int ret;
 
     ff_mobiclipencdsp_init(&s->dsp);
@@ -1615,6 +3607,11 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
                "MobiClip qp must be in the range 12..63\n");
         return AVERROR(EINVAL);
     }
+    if (avctx->refs < 1 || avctx->refs > MOBI_MAX_REFERENCES) {
+        av_log(avctx, AV_LOG_ERROR,
+               "MobiClip reference count must be in the range 1..5\n");
+        return AVERROR(EINVAL);
+    }
     if (avctx->profile != AV_PROFILE_MOBICLIP_MOC5 &&
         avctx->profile != AV_PROFILE_MOBICLIP_MOFLEX) {
         av_log(avctx, AV_LOG_ERROR,
@@ -1624,10 +3621,61 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
     s->moflex = avctx->profile == AV_PROFILE_MOBICLIP_MOFLEX;
     if ((ret = av_image_check_size(avctx->width, avctx->height, 0, avctx)) < 0)
         return ret;
+    if (pass_flags) {
+        av_log(avctx, AV_LOG_ERROR,
+               "MobiClip rate control is not supported\n");
+        return AVERROR(EINVAL);
+    }
+    if (avctx->gop_size < 0)
+        avctx->gop_size = MOBI_DEFAULT_GOP_SIZE;
 
+    if (!avctx->thread_count)
+        avctx->thread_count = FFMAX(av_cpu_count(), 1);
+
+    for (int size_bits = 1; size_bits <= 4; size_bits++) {
+        s->sad[size_bits - 1] =
+            av_pixelutils_get_sad_fn(size_bits, size_bits, 0, avctx);
+        if (!s->sad[size_bits - 1]) {
+            av_log(avctx, AV_LOG_ERROR,
+                   "Could not initialize %dx%d SAD function\n",
+                   1 << size_bits, 1 << size_bits);
+            return AVERROR(EINVAL);
+        }
+    }
+
+    for (int i = 0; i < FF_ARRAY_ELEMS(s->reference); i++) {
+        s->reference[i] = av_frame_alloc();
+        if (!s->reference[i])
+            return AVERROR(ENOMEM);
+    }
+    s->me_stride = FFALIGN(avctx->width, 32);
+    s->halfpel_stride = FFALIGN(avctx->width, 32);
+    s->me_column_sum =
+        av_malloc_array(avctx->width, sizeof(*s->me_column_sum));
+    for (int i = 0; i < FF_ARRAY_ELEMS(s->me_reference); i++) {
+        s->me_reference[i] =
+            av_mallocz(s->me_stride * (size_t)avctx->height);
+        if (!s->me_reference[i])
+            return AVERROR(ENOMEM);
+        for (int plane = 0;
+             plane < FF_ARRAY_ELEMS(s->halfpel_reference[i]); plane++) {
+            s->halfpel_reference[i][plane] =
+                av_mallocz(s->halfpel_stride * (size_t)avctx->height);
+            if (!s->halfpel_reference[i][plane])
+                return AVERROR(ENOMEM);
+        }
+    }
     s->recon = av_frame_alloc();
-    if (!s->recon)
+    s->motion = av_calloc(avctx->width / 16 + 3, sizeof(*s->motion));
+    if (!s->me_column_sum || !s->recon || !s->motion)
         return AVERROR(ENOMEM);
+    if (avctx->thread_count > 1) {
+        ret = avpriv_slicethread_create(&s->reference_threads, s,
+                                        build_reference_worker, NULL, 2);
+        if (ret < 0)
+            return ret;
+    }
+
     s->recon->format = avctx->pix_fmt;
     s->recon->width  = avctx->width;
     s->recon->height = avctx->height;
@@ -1635,8 +3683,11 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
         return ret;
 
     mobi_set_qp(s, s->qp);
+
     setup_qtable(s);
     setup_rl_codes(s);
+    s->previous_qp = s->qp;
+    s->last_key_frame = -1;
     s->input = av_frame_alloc();
     if (!s->input)
         return AVERROR(ENOMEM);
@@ -1648,8 +3699,54 @@ static av_cold int mobiclip_encode_close(AVCodecContext *avctx)
 {
     MobiClipEncContext *s = avctx->priv_data;
 
+    if (s->frame_count && (avctx->flags & AV_CODEC_FLAG_PSNR)) {
+        long double samples = s->frame_count * (long double)avctx->width *
+                              avctx->height * 3 / 2;
+        long double mse = s->total_sse / samples;
+        double psnr = mse > 0 ? 10 * log10(255.0 * 255.0 / mse) : INFINITY;
+
+        av_log(avctx, AV_LOG_INFO,
+               "MobiClip reconstruction SSE %.0Lf, PSNR %.4f dB\n",
+               s->total_sse, psnr);
+    }
+
+    for (int i = 0; i < FF_ARRAY_ELEMS(s->reference); i++)
+        av_frame_free(&s->reference[i]);
+    for (int i = 0; i < FF_ARRAY_ELEMS(s->me_reference); i++)
+        av_freep(&s->me_reference[i]);
+    for (int i = 0; i < FF_ARRAY_ELEMS(s->halfpel_reference); i++)
+        for (int plane = 0;
+             plane < FF_ARRAY_ELEMS(s->halfpel_reference[i]); plane++)
+            av_freep(&s->halfpel_reference[i][plane]);
+    av_freep(&s->me_column_sum);
+    avpriv_slicethread_free(&s->reference_threads);
     av_frame_free(&s->recon);
     av_frame_free(&s->input);
+    av_freep(&s->motion);
+    return 0;
+}
+
+static int encode_frame_attempt(AVCodecContext *avctx, AVPacket *pkt,
+                                int max_size, const AVFrame *frame, int key,
+                                int *size)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+    PutBitContext pb;
+    int ret;
+
+    setup_qtable(s);
+    if ((ret = av_frame_make_writable(s->recon)) < 0)
+        return ret;
+
+    init_put_bits(&pb, pkt->data, max_size);
+    if (key)
+        ret = encode_intra(avctx, &pb, frame);
+    else
+        ret = encode_inter(avctx, &pb, frame);
+    if (ret < 0)
+        return ret;
+    flush_put_bits(&pb);
+    *size = put_bytes_output(&pb);
     return 0;
 }
 
@@ -1658,9 +3755,12 @@ static int mobiclip_encode_frame(AVCodecContext *avctx, AVPacket *pkt,
 {
     MobiClipEncContext *s = avctx->priv_data;
     int64_t samples = avctx->width * (int64_t)avctx->height * 3 / 2;
-    int64_t max_size = samples * 4 +
-                       avctx->width * (int64_t)avctx->height / 16 + 64;
-    PutBitContext pb;
+    int64_t max_size = samples * 4 + avctx->width * (int64_t)avctx->height / 16 + 64;
+    int key = !s->frame_count ||
+              (frame && frame->pict_type == AV_PICTURE_TYPE_I) ||
+              (avctx->gop_size > 0 && s->last_key_frame >= 0 &&
+               s->frame_count - s->last_key_frame >= avctx->gop_size);
+    int64_t frame_bits;
     int ret, size;
 
     if (!frame)
@@ -1669,14 +3769,10 @@ static int mobiclip_encode_frame(AVCodecContext *avctx, AVPacket *pkt,
         return AVERROR(ERANGE);
     if ((ret = ff_get_encode_buffer(avctx, pkt, max_size, 0)) < 0)
         return ret;
-    if ((ret = av_frame_make_writable(s->recon)) < 0)
-        return ret;
 
-    init_put_bits(&pb, pkt->data, max_size);
-    if ((ret = encode_intra(avctx, &pb, frame)) < 0)
+    if ((ret = encode_frame_attempt(avctx, pkt, max_size, frame, key,
+                                    &size)) < 0)
         return ret;
-    flush_put_bits(&pb);
-    size = put_bytes_output(&pb);
 
     /* Packets are stored with the two bytes of every 16-bit word swapped. */
     if (size & 1)
@@ -1684,8 +3780,52 @@ static int mobiclip_encode_frame(AVCodecContext *avctx, AVPacket *pkt,
     for (int i = 0; i < size; i += 2)
         FFSWAP(uint8_t, pkt->data[i], pkt->data[i + 1]);
     pkt->size = size;
-    pkt->flags |= AV_PKT_FLAG_KEY;
+    frame_bits = 8LL * size;
+
+    if (avctx->flags & AV_CODEC_FLAG_PSNR) {
+        uint64_t error[3];
+        uint64_t distortion = frame_sse(avctx, frame, s->recon, error);
+
+        s->total_sse += distortion;
+        for (int plane = 0; plane < 3; plane++)
+            avctx->error[plane] += error[plane];
+        av_log(avctx, AV_LOG_DEBUG,
+               "MobiClip frame %" PRId64 " type=%c qp=%d bits=%" PRId64
+               " sse=%" PRIu64 "\n",
+               s->frame_count, key ? 'I' : 'P', s->qp, frame_bits,
+               distortion);
+    }
+
+    if (key) {
+        pkt->flags |= AV_PKT_FLAG_KEY;
+        s->last_key_frame = s->frame_count;
+        s->frames_since_key = 1;
+    } else {
+        s->frames_since_key++;
+    }
+    if (s->reference_threads) {
+        s->reference_source = s->recon;
+        s->reference_slot = s->current_reference;
+        avpriv_slicethread_execute(s->reference_threads, 2, 0);
+    } else {
+        build_me_reference(s->recon, s->me_reference[s->current_reference],
+                           s->me_stride, s->me_column_sum);
+        build_halfpel_reference(
+            &s->dsp,
+            s->recon, s->halfpel_reference[s->current_reference],
+            s->halfpel_stride);
+    }
+    if ((ret = av_frame_replace(s->reference[s->current_reference],
+                                s->recon)) < 0)
+        return ret;
+    if ((avctx->flags & AV_CODEC_FLAG_RECON_FRAME) &&
+        (ret = av_frame_replace(avctx->internal->recon_frame, s->recon)) < 0)
+        return ret;
+    s->current_reference =
+        (s->current_reference + 1) % MOBI_REFERENCE_SLOTS;
+    s->previous_qp = s->qp;
     pkt->duration = 1;
+    s->frame_count++;
     *got_packet = 1;
     return 0;
 }
@@ -1732,7 +3872,9 @@ static const AVOption mobiclip_options[] = {
                             VIDEO, AV_PROFILE_MOBICLIP_MOC5)
     FF_AVCTX_PROFILE_OPTION("moflex", "use the flexible bitstream profile",
                             VIDEO, AV_PROFILE_MOBICLIP_MOFLEX)
-    { "qp", "set the direct MobiClip bitstream quantizer",
+    { "mixed_refs", "allow each split partition to select its reference age",
+      OFFSET(mixed_refs), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, VE },
+    { "qp", "set the MobiClip bitstream quantizer",
       OFFSET(qp), AV_OPT_TYPE_INT, { .i64 = MOBI_DEFAULT_QP },
       MOBI_MIN_QP, MOBI_MAX_QP, VE },
     { NULL },
@@ -1746,7 +3888,10 @@ static const AVClass mobiclip_encoder_class = {
 };
 
 static const FFCodecDefault mobiclip_defaults[] = {
+    { "refs", AV_STRINGIFY(MOBI_MAX_REFERENCES) },
     { "profile", "moflex" },
+    { "g", AV_STRINGIFY(MOBI_DEFAULT_GOP_SIZE) },
+    { "threads", "0" },
     { NULL },
 };
 
@@ -1755,8 +3900,9 @@ const FFCodec ff_mobiclip_encoder = {
     CODEC_LONG_NAME("MobiClip Video"),
     .p.type         = AVMEDIA_TYPE_VIDEO,
     .p.id           = AV_CODEC_ID_MOBICLIP,
-    .p.capabilities = AV_CODEC_CAP_DR1 |
-                      AV_CODEC_CAP_ENCODER_REORDERED_OPAQUE,
+    .p.capabilities = AV_CODEC_CAP_DR1 | AV_CODEC_CAP_OTHER_THREADS |
+                      AV_CODEC_CAP_ENCODER_REORDERED_OPAQUE |
+                      AV_CODEC_CAP_ENCODER_RECON_FRAME,
     .priv_data_size = sizeof(MobiClipEncContext),
     .init           = mobiclip_encode_init,
     FF_CODEC_RECEIVE_PACKET_CB(mobiclip_receive_packet),
@@ -1766,5 +3912,6 @@ const FFCodec ff_mobiclip_encoder = {
     .color_ranges   = AVCOL_RANGE_MPEG,
     .p.profiles     = NULL_IF_CONFIG_SMALL(ff_mobiclip_profiles),
     .p.priv_class   = &mobiclip_encoder_class,
-    .caps_internal  = FF_CODEC_CAP_INIT_CLEANUP,
+    .caps_internal  = FF_CODEC_CAP_INIT_CLEANUP |
+                      FF_CODEC_CAP_AUTO_THREADS,
 };
diff --git a/libavcodec/mobiclipencdsp.c b/libavcodec/mobiclipencdsp.c
index 9e3d935112..8dcd72dc74 100644
--- a/libavcodec/mobiclipencdsp.c
+++ b/libavcodec/mobiclipencdsp.c
@@ -19,8 +19,6 @@
  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
  */
 
-#include "config.h"
-
 #include "libavutil/attributes.h"
 #include "libavutil/common.h"
 
@@ -275,6 +273,58 @@ static void quant8_c(int16_t level[64], const int16_t coeff[64],
     }
 }
 
+static void halfpel_filter_c(uint8_t *dst, const uint8_t *src0,
+                             const uint8_t *src1, int width)
+{
+    for (int x = 0; x < width; x++)
+        dst[x] = (src0[x] >> 1) + (src1[x] >> 1);
+}
+
+#define SAD_C(width, height)                                                \
+static int sad_ ## width ## x ## height ## _c(                             \
+    const uint8_t *src, ptrdiff_t src_stride,                              \
+    const uint8_t *reference, ptrdiff_t reference_stride)                  \
+{                                                                           \
+    int sad = 0;                                                            \
+                                                                            \
+    for (int y = 0; y < height; y++) {                                     \
+        for (int x = 0; x < width; x++)                                    \
+            sad += FFABS(src[x] - reference[x]);                           \
+        src       += src_stride;                                            \
+        reference += reference_stride;                                      \
+    }                                                                       \
+    return sad;                                                             \
+}
+
+SAD_C(4, 4)
+SAD_C(4, 8)
+SAD_C(4, 16)
+SAD_C(8, 4)
+SAD_C(8, 8)
+SAD_C(8, 16)
+SAD_C(16, 4)
+SAD_C(16, 8)
+SAD_C(16, 16)
+
+static void sad_x4_c(int sad[4], const uint8_t *src, ptrdiff_t src_stride,
+                     const uint8_t *const reference[4],
+                     ptrdiff_t reference_stride, int width, int height)
+{
+    const uint8_t *ref[4];
+
+    memcpy(ref, reference, sizeof(ref));
+    memset(sad, 0, 4 * sizeof(*sad));
+    for (int y = 0; y < height; y++) {
+        for (int x = 0; x < width; x++) {
+            for (int lane = 0; lane < 4; lane++)
+                sad[lane] += FFABS(src[x] - ref[lane][x]);
+        }
+        src += src_stride;
+        for (int lane = 0; lane < 4; lane++)
+            ref[lane] += reference_stride;
+    }
+}
+
 av_cold void ff_mobiclipencdsp_init(MobiClipEncDSPContext *c)
 {
     c->residual_fdct4 = residual_fdct4_c;
@@ -283,4 +333,15 @@ av_cold void ff_mobiclipencdsp_init(MobiClipEncDSPContext *c)
     c->reconstruct8x8 = reconstruct8x8_c;
     c->quant4         = quant4_c;
     c->quant8         = quant8_c;
+    c->halfpel_filter = halfpel_filter_c;
+    c->sad[0][0]      = sad_4x4_c;
+    c->sad[0][1]      = sad_4x8_c;
+    c->sad[0][2]      = sad_4x16_c;
+    c->sad[1][0]      = sad_8x4_c;
+    c->sad[1][1]      = sad_8x8_c;
+    c->sad[1][2]      = sad_8x16_c;
+    c->sad[2][0]      = sad_16x4_c;
+    c->sad[2][1]      = sad_16x8_c;
+    c->sad[2][2]      = sad_16x16_c;
+    c->sad_x4         = sad_x4_c;
 }
diff --git a/libavcodec/mobiclipencdsp.h b/libavcodec/mobiclipencdsp.h
index 99a7d3b55c..6191f31739 100644
--- a/libavcodec/mobiclipencdsp.h
+++ b/libavcodec/mobiclipencdsp.h
@@ -25,7 +25,16 @@
 #include <stddef.h>
 #include <stdint.h>
 
-#include "libavutil/attributes.h"
+typedef int (*mobiclip_sad_fn)(const uint8_t *src, ptrdiff_t src_stride,
+                               const uint8_t *reference,
+                               ptrdiff_t reference_stride);
+
+typedef void (*mobiclip_sad_x4_fn)(int sad[4],
+                                  const uint8_t *src,
+                                  ptrdiff_t src_stride,
+                                  const uint8_t *const reference[4],
+                                  ptrdiff_t reference_stride,
+                                  int width, int height);
 
 typedef struct MobiClipEncDSPContext {
     int (*residual_fdct4)(int16_t residual[16], int16_t coeff[16],
@@ -47,8 +56,12 @@ typedef struct MobiClipEncDSPContext {
                   const int16_t multiplier[16], int shift, int bias);
     void (*quant8)(int16_t level[64], const int16_t coeff[64],
                    const int16_t multiplier[64], int shift, int bias);
+    void (*halfpel_filter)(uint8_t *dst, const uint8_t *src0,
+                           const uint8_t *src1, int width);
+    mobiclip_sad_fn sad[3][3];
+    mobiclip_sad_x4_fn sad_x4;
 } MobiClipEncDSPContext;
 
-av_cold void ff_mobiclipencdsp_init(MobiClipEncDSPContext *c);
+void ff_mobiclipencdsp_init(MobiClipEncDSPContext *c);
 
 #endif /* AVCODEC_MOBICLIPENCDSP_H */
diff --git a/tests/fate/mobiclip.mak b/tests/fate/mobiclip.mak
index 75a2043fea..cd1b200148 100644
--- a/tests/fate/mobiclip.mak
+++ b/tests/fate/mobiclip.mak
@@ -11,22 +11,54 @@ FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)) += \
     fate-mobiclip-moc5-roundtrip \
     fate-mobiclip-moc5-intra-modes \
     fate-mobiclip-moc5-qp-lambda \
+    fate-mobiclip-moc5-intra-probe-state \
     fate-mobiclip-moc5-deferred-intra8-selector \
     fate-mobiclip-moc5-zero-mask-intra-split \
+    fate-mobiclip-moc5-partitions \
+    fate-mobiclip-moc5-nested-partitions \
+    fate-mobiclip-moc5-deep-partitions \
+    fate-mobiclip-moc5-mixed-partitions \
     fate-mobiclip-moc5-certificate-only
-fate-mobiclip-moc5-roundtrip: CMD = mobiclip_moc5_roundtrip 128 80 25 12 1 20
+fate-mobiclip-moc5-roundtrip: CMD = mobiclip_moc5_roundtrip 128 80 25 12 4 20
 fate-mobiclip-moc5-roundtrip: CMP = null
 fate-mobiclip-moc5-intra-modes: CMD = mobiclip_moc5_intra_modes
 fate-mobiclip-moc5-intra-modes: CMP = null
 fate-mobiclip-moc5-qp-lambda: CMD = mobiclip_moc5_qp_lambda
 fate-mobiclip-moc5-qp-lambda: CMP = null
+fate-mobiclip-moc5-intra-probe-state: CMD = mobiclip_moc5_intra_probe_state
+fate-mobiclip-moc5-intra-probe-state: CMP = null
 fate-mobiclip-moc5-deferred-intra8-selector: CMD = mobiclip_moc5_deferred_intra8_selector
 fate-mobiclip-moc5-deferred-intra8-selector: CMP = null
 fate-mobiclip-moc5-zero-mask-intra-split: CMD = mobiclip_moc5_zero_mask_intra_split
 fate-mobiclip-moc5-zero-mask-intra-split: CMP = null
+fate-mobiclip-moc5-partitions: CMD = mobiclip_moc5_partitions
+fate-mobiclip-moc5-partitions: CMP = null
+fate-mobiclip-moc5-nested-partitions: CMD = mobiclip_moc5_nested_partitions
+fate-mobiclip-moc5-nested-partitions: CMP = null
+fate-mobiclip-moc5-deep-partitions: CMD = mobiclip_moc5_deep_partitions
+fate-mobiclip-moc5-deep-partitions: CMP = null
+fate-mobiclip-moc5-mixed-partitions: CMD = mobiclip_moc5_mixed_partitions
+fate-mobiclip-moc5-mixed-partitions: CMP = null
 fate-mobiclip-moc5-certificate-only: CMD = mobiclip_moc5_certificate_only
 fate-mobiclip-moc5-certificate-only: CMP = null
 
+FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)) += \
+    fate-mobiclip-moc5-reference-drift
+fate-mobiclip-moc5-reference-drift: tools/enc_recon_frame_test$(EXESUF)
+fate-mobiclip-moc5-reference-drift: CMD = mobiclip_moc5_reference_drift
+fate-mobiclip-moc5-reference-drift: CMP = null
+
+FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS) COLOR_FILTER) += \
+    fate-mobiclip-moc5-direct-motion \
+    fate-mobiclip-moc5-multiref \
+    fate-mobiclip-moc5-p-intra
+fate-mobiclip-moc5-direct-motion: CMD = mobiclip_moc5_direct_motion
+fate-mobiclip-moc5-direct-motion: CMP = null
+fate-mobiclip-moc5-multiref: CMD = mobiclip_moc5_multiref
+fate-mobiclip-moc5-multiref: CMP = null
+fate-mobiclip-moc5-p-intra: CMD = mobiclip_moc5_p_intra
+fate-mobiclip-moc5-p-intra: CMP = null
+
 MOBICLIP_MOC5_ADPCM_DEPS = $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)          \
                            SINE_FILTER ARESAMPLE_FILTER             \
                            PCM_S16LE_MUXER PCM_S16LE_DEMUXER        \
diff --git a/tests/mobiclip-fate.sh b/tests/mobiclip-fate.sh
index 2b2b13eccc..ce44b95658 100644
--- a/tests/mobiclip-fate.sh
+++ b/tests/mobiclip-fate.sh
@@ -29,7 +29,7 @@ mobiclip_moc5_roundtrip(){
     height=${2:-80}
     rate=${3:-25}
     frames=${4:-12}
-    gop=${5:-1}
+    gop=${5:-4}
     min_psnr=${6:-20}
     profile=${7:-0}
     compact_ki=${8:-0}
@@ -109,6 +109,100 @@ mobiclip_moc5_roundtrip(){
     }
 }
 
+
+# Verify that a repeated flat frame collapses to true symbol-0 skip
+# macroblocks in both profiles.
+mobiclip_moc5_direct_motion(){
+    width=128
+    height=16
+    rate=25
+
+    for profile in 0 1; do
+        skip="${outdir}/${test}.skip-${profile}.mo"
+        cleanfiles="$cleanfiles $skip"
+        tskip=$(target_path "$skip")
+
+        ffmpeg -auto_conversion_filters -f lavfi \
+            -i "color=c=gray:size=${width}x${height}:rate=${rate}" \
+            -frames:v 2 -an -c:v mobiclip -profile:v "$profile" \
+            -qp 24 -g 10 -f mobiclip_mo -y "$tskip" || return
+        second_size=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+            -select_streams v:0 -show_entries packet=size -of csv=p=0 \
+            "$tskip" | sed -n '2p') || return
+        [ "$second_size" -eq 4 ] || {
+            echo "profile $profile true-skip packet is $second_size bytes, expected 4" >&2
+            return 1
+        }
+    done
+}
+
+
+# A textured direct-QP P picture chooses predicted intra for its first root
+# macroblock. Check the exact VlcTable 0/1 root prefixes and decode the
+# table-0 residual payload used by P-intra mode 7.
+mobiclip_moc5_p_intra(){
+    width=128
+    height=16
+    rate=25
+    black="${outdir}/${test}.black.yuv"
+    texture="${outdir}/${test}.texture.yuv"
+    source="${outdir}/${test}.source.yuv"
+    cleanfiles="$cleanfiles $black $texture $source"
+    tblack=$(target_path "$black")
+    ttexture=$(target_path "$texture")
+    tsource=$(target_path "$source")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "color=c=black:size=${width}x${height}:rate=${rate}" \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tblack" || return
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$ttexture" || return
+    cat "$black" "$texture" > "$source" || return
+
+    for profile in 0 1; do
+        encoded="${outdir}/${test}.${profile}.mo"
+        decoded="${outdir}/${test}.${profile}.yuv"
+        cleanfiles="$cleanfiles $encoded $decoded"
+        tencoded=$(target_path "$encoded")
+        tdecoded=$(target_path "$decoded")
+
+        ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+            -video_size "${width}x${height}" -framerate "$rate" \
+            -i "$tsource" -frames:v 2 -an -c:v mobiclip \
+            -profile:v "$profile" -qp 24 -g 10 \
+            -f mobiclip_mo -y "$tencoded" || return
+        second_pos=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+            -select_streams v:0 -show_entries packet=pos -of csv=p=0 \
+            "$tencoded" | sed -n '2p') || return
+        set -- $(od -An -v -tu1 -j $((second_pos + 8)) -N 2 "$encoded")
+        [ "$#" -eq 2 ] || return 1
+        logical_byte=$2
+        if [ "$profile" -eq 1 ]; then
+            [ $((logical_byte & 0xfe)) -eq 88 ] || {
+                echo "Moflex P-intra root is 0x$(printf %02x "$logical_byte"), expected mode 7" >&2
+                return 1
+            }
+        else
+            [ $((logical_byte & 0xfe)) -eq 104 ] || {
+                echo "MO P-intra root is 0x$(printf %02x "$logical_byte"), expected mode 7" >&2
+                return 1
+            }
+        fi
+
+        ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+            -frames:v 2 -pix_fmt yuv420p -c:v rawvideo \
+            -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+        [ "$(wc -c < "$decoded")" -eq $((width * height * 3)) ] || {
+            echo "profile $profile P-intra output did not decode both frames" >&2
+            return 1
+        }
+    done
+}
+
+
 # Intra RDO can choose a four-4x4 prediction route even when all
 # four residual blocks quantize to zero. The i4 selector still carries the
 # split and its four prediction modes. The exact plane-offset search keeps
@@ -154,6 +248,7 @@ mobiclip_moc5_zero_mask_intra_split(){
     }
 }
 
+
 # Exercise the I-picture mode-6/mode-7 selector on a small textured image.
 # The exact packet size also locks hierarchical 16x16 -> 8x8 ->
 # 4x4 availability propagation, while a decoder round trip catches ordering
@@ -200,6 +295,7 @@ mobiclip_moc5_intra_modes(){
     done
 }
 
+
 # Choose the predicted 8x8 mode before adding the transform-selector
 # lambda, and measures transform distortion in the residual domain before
 # prediction addition can clip to uint8. Lock the complete deterministic
@@ -242,6 +338,7 @@ mobiclip_moc5_deferred_intra8_selector(){
     }
 }
 
+
 # The RDO lambda follows 0.035 * exp(qp * ln(2) / 3). Lock the complete
 # QP-48 packet because small changes to the curve alter mode decisions without
 # necessarily changing the aligned packet size.
@@ -283,6 +380,584 @@ mobiclip_moc5_qp_lambda(){
     }
 }
 
+
+# The fixed-DC intra probe keeps its prediction samples in the working
+# plane: residual evaluation writes only coefficient/reconstruction scratch.
+# The selector also leaves that probe state in place when it enters the full
+# intra search. Reconstructing each probe unit, or restoring the best inter
+# pixels first, changes the final P macroblock from root 6 to root 8.
+mobiclip_moc5_intra_probe_state(){
+    width=16
+    height=64
+    encoded="${outdir}/${test}.mo"
+    decoded="${outdir}/${test}.yuv"
+    cleanfiles="$cleanfiles $encoded $decoded"
+    tencoded=$(target_path "$encoded")
+    tdecoded=$(target_path "$decoded")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=1" \
+        -frames:v 2 -pix_fmt yuv420p -an -c:v mobiclip \
+        -profile:v moflex -qp 31 -g 10 \
+        -f mobiclip_mo -y "$tencoded" || return
+    packet_count=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=size -of csv=p=0 \
+        "$tencoded" | wc -l) || return
+    [ "$packet_count" -eq 2 ] || {
+        echo "intra-probe fixture emitted $packet_count packets, expected 2" >&2
+        return 1
+    }
+    packet_size=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=size -of csv=p=0 \
+        "$tencoded" | sed -n '2p') || return
+    [ "$packet_size" -eq 248 ] || {
+        echo "intra-probe P packet is $packet_size bytes, expected 248" >&2
+        return 1
+    }
+    packet_hash=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_packets -show_entries packet=data_hash \
+        -show_data_hash SHA256 -of csv=p=0 "$tencoded" | sed -n '2p') || return
+    [ "$packet_hash" = \
+      "SHA256:979b761bc39627cce000c07b626eab223c7f588cbfe4f4bcc992cb88a4060a0a" ] || {
+        echo "intra-probe P-packet hash is $packet_hash" >&2
+        return 1
+    }
+
+    ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+        -frames:v 2 -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+    [ "$(wc -c < "$decoded")" -eq $((width * height * 3)) ] || {
+        echo "intra-probe output did not decode two frames" >&2
+        return 1
+    }
+}
+
+
+# Alternate two flat frames so frame three has an exact match only at
+# reference age two.  The first root symbol is checked in both frame profiles,
+# then the complete stream is decoded to exercise the rotating six-slot pool.
+mobiclip_moc5_multiref(){
+    width=128
+    height=16
+    rate=25
+    black="${outdir}/${test}.black.yuv"
+    white="${outdir}/${test}.white.yuv"
+    source="${outdir}/${test}.source.yuv"
+    cleanfiles="$cleanfiles $black $white $source"
+    tblack=$(target_path "$black")
+    twhite=$(target_path "$white")
+    tsource=$(target_path "$source")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "color=c=black:size=${width}x${height}:rate=${rate}" \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tblack" || return
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "color=c=white:size=${width}x${height}:rate=${rate}" \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$twhite" || return
+    cat "$black" "$white" "$black" > "$source" || return
+
+    for profile in 0 1; do
+        encoded="${outdir}/${test}.${profile}.mo"
+        decoded="${outdir}/${test}.${profile}.yuv"
+        keyreset="${outdir}/${test}.keyreset-${profile}.mo"
+        keydecoded="${outdir}/${test}.keyreset-${profile}.yuv"
+        cleanfiles="$cleanfiles $encoded $decoded $keyreset $keydecoded"
+        tencoded=$(target_path "$encoded")
+        tdecoded=$(target_path "$decoded")
+        tkeyreset=$(target_path "$keyreset")
+        tkeydecoded=$(target_path "$keydecoded")
+
+        ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+            -video_size "${width}x${height}" -framerate "$rate" \
+            -i "$tsource" -frames:v 3 -an -c:v mobiclip -refs 5 \
+            -profile:v "$profile" -qp 24 -g 10 \
+            -f mobiclip_mo -y "$tencoded" || return
+        third_pos=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+            -select_streams v:0 -show_entries packet=pos -of csv=p=0 \
+            "$tencoded" | sed -n '3p') || return
+        set -- $(od -An -v -tu1 -j $((third_pos + 8)) -N 2 "$encoded")
+        [ "$#" -eq 2 ] || return 1
+        logical_byte=$2
+        if [ "$profile" -eq 1 ]; then
+            [ $((logical_byte & 0xfc)) -eq 72 ] || {
+                echo "Moflex alternating cycle did not select root reference 2" >&2
+                return 1
+            }
+        else
+            [ $((logical_byte & 0xfc)) -eq 100 ] || {
+                echo "MO alternating cycle did not select root reference 2" >&2
+                return 1
+            }
+        fi
+
+        ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+            -frames:v 3 -pix_fmt yuv420p -c:v rawvideo \
+            -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+        expected_size=$((width * height * 9 / 2))
+        [ "$(wc -c < "$decoded")" -eq "$expected_size" ] || {
+            echo "profile $profile multireference output did not decode all frames" >&2
+            return 1
+        }
+
+        # The same cycle with its middle frame forced to I keeps the physical
+        # ring intact, but reference age two is no longer available.
+        ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+            -video_size "${width}x${height}" -framerate "$rate" \
+            -i "$tsource" -frames:v 3 -an -c:v mobiclip -refs 5 \
+            -profile:v "$profile" -qp 24 -g 10 \
+            -force_key_frames "expr:eq(n,1)" \
+            -f mobiclip_mo -y "$tkeyreset" || return
+        third_pos=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+            -select_streams v:0 -show_entries packet=pos -of csv=p=0 \
+            "$tkeyreset" | sed -n '3p') || return
+        set -- $(od -An -v -tu1 -j $((third_pos + 8)) -N 2 "$keyreset")
+        [ "$#" -eq 2 ] || return 1
+        logical_byte=$2
+        if [ "$profile" -eq 1 ]; then
+            [ $((logical_byte & 0xfc)) -ne 72 ] || {
+                echo "Moflex reference availability crossed a keyframe" >&2
+                return 1
+            }
+        else
+            [ $((logical_byte & 0xfc)) -ne 100 ] || {
+                echo "MO reference availability crossed a keyframe" >&2
+                return 1
+            }
+        fi
+        ffmpeg -auto_conversion_filters -i "$tkeyreset" -map 0:v:0 \
+            -frames:v 3 -pix_fmt yuv420p -c:v rawvideo \
+            -fps_mode passthrough -f rawvideo -y "$tkeydecoded" || return
+    done
+}
+
+
+# Keep more than four complete rotations of the six-picture reference ring.
+# enc_recon_frame_test Adler-hashes every plane of every exported encoder
+# reconstruction and independently decoded frame, and fails at the first
+# mismatch.  This catches syntactically valid streams whose reference pixels
+# diverge and then compound through later motion references.
+mobiclip_moc5_reference_drift(){
+    width=64
+    height=48
+    rate=25
+    frames=32
+    source="${outdir}/${test}.source.mo"
+    cleanfiles="$cleanfiles $source"
+    tsource=$(target_path "$source")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v mobiclip \
+        -profile:v moflex -qp 12 -refs 5 -g 64 \
+        -f mobiclip_mo -y "$tsource" || return
+
+    for profile in 0 1; do
+        run tools/enc_recon_frame_test "$tsource" mobiclip \
+            "profile=${profile},qp=24,refs=5,g=64" "$frames" || return
+    done
+}
+
+
+# Give the two halves of the first macroblock different exact motions.  This
+# forces the recursive partition path and locks both root split VLCs
+# against the decoder's transposed partition-class order.
+mobiclip_moc5_partitions(){
+    rate=25
+
+    for orientation in h v; do
+        if [ "$orientation" = h ]; then
+            width=32
+            height=16
+        else
+            width=16
+            height=32
+        fi
+        source="${outdir}/${test}.${orientation}.source.yuv"
+        cleanfiles="$cleanfiles $source"
+        tsource=$(target_path "$source")
+
+        awk -v w="$width" -v h="$height" -v orientation="$orientation" '
+            BEGIN {
+                for (y = 0; y < h; y++)
+                    for (x = 0; x < w; x++)
+                        pixel[y * w + x] = 32 + ((x * 37 + y * 53 + x * y * 17) % 95);
+                for (i = 0; i < w * h; i++)
+                    printf "%c", pixel[i];
+                for (i = 0; i < w * h / 2; i++)
+                    printf "%c", 64;
+                for (y = 0; y < h; y++) {
+                    for (x = 0; x < w; x++) {
+                        sx = x;
+                        sy = y;
+                        if (orientation == "h" && y >= 8)
+                            sx = x + 2 < w ? x + 2 : w - 1;
+                        if (orientation == "v" && x >= 8)
+                            sy = y + 2 < h ? y + 2 : h - 1;
+                        printf "%c", pixel[sy * w + sx];
+                    }
+                }
+                for (i = 0; i < w * h / 2; i++)
+                    printf "%c", 64;
+            }
+        ' > "$source" || return
+
+        for profile in 0 1; do
+            encoded="${outdir}/${test}.${orientation}-${profile}.mo"
+            decoded="${outdir}/${test}.${orientation}-${profile}.yuv"
+            cleanfiles="$cleanfiles $encoded $decoded"
+            tencoded=$(target_path "$encoded")
+            tdecoded=$(target_path "$decoded")
+
+            ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+                -video_size "${width}x${height}" -framerate "$rate" \
+                -i "$tsource" -frames:v 2 -an -c:v mobiclip \
+                -profile:v "$profile" -qp 24 -g 10 \
+                -f mobiclip_mo -y "$tencoded" || return
+            second_pos=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+                -select_streams v:0 -show_entries packet=pos -of csv=p=0 \
+                "$tencoded" | sed -n '2p') || return
+            set -- $(od -An -v -tu1 -j $((second_pos + 8)) -N 2 "$encoded")
+            [ "$#" -eq 2 ] || return 1
+            logical_byte=$2
+
+            if [ "$profile" -eq 0 ]; then
+                expected=$([ "$orientation" = h ] && echo 80 || echo 88)
+                mask=248
+            elif [ "$orientation" = h ]; then
+                expected=64
+                mask=248
+            else
+                expected=92
+                mask=252
+            fi
+            [ $((logical_byte & mask)) -eq "$expected" ] || {
+                echo "profile $profile $orientation split root is 0x$(printf %02x "$logical_byte")" >&2
+                return 1
+            }
+
+            ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+                -frames:v 2 -pix_fmt yuv420p -c:v rawvideo \
+                -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+            [ "$(wc -c < "$decoded")" -eq $((width * height * 3)) ] || {
+                echo "profile $profile $orientation split did not decode both frames" >&2
+                return 1
+            }
+        done
+    done
+}
+
+
+# Make the first macroblock's four 8x8 quadrants use different exact motions.
+# Both profiles must split the root vertically and then split its first 8x16
+# child horizontally, exercising a real recursive tree rather than only the
+# root partition.
+mobiclip_moc5_nested_partitions(){
+    width=32
+    height=32
+    rate=25
+    source="${outdir}/${test}.source.yuv"
+    cleanfiles="$cleanfiles $source"
+    tsource=$(target_path "$source")
+
+    awk -v w="$width" -v h="$height" '
+        BEGIN {
+            for (y = 0; y < h; y++)
+                for (x = 0; x < w; x++)
+                    pixel[y * w + x] = 32 + ((x * 37 + y * 53 + x * y * 17) % 95);
+            for (i = 0; i < w * h; i++)
+                printf "%c", pixel[i];
+            for (i = 0; i < w * h / 2; i++)
+                printf "%c", 64;
+            for (y = 0; y < h; y++) {
+                for (x = 0; x < w; x++) {
+                    sx = x;
+                    sy = y;
+                    if (x < 16 && y < 16) {
+                        if (x >= 8)
+                            sx += 2;
+                        if (y >= 8)
+                            sy += 2;
+                    }
+                    printf "%c", pixel[sy * w + sx];
+                }
+            }
+            for (i = 0; i < w * h / 2; i++)
+                printf "%c", 64;
+        }
+    ' > "$source" || return
+
+    for profile in 0 1; do
+        encoded="${outdir}/${test}.${profile}.mo"
+        decoded="${outdir}/${test}.${profile}.yuv"
+        cleanfiles="$cleanfiles $encoded $decoded"
+        tencoded=$(target_path "$encoded")
+        tdecoded=$(target_path "$decoded")
+
+        ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+            -video_size "${width}x${height}" -framerate "$rate" \
+            -i "$tsource" -frames:v 2 -an -c:v mobiclip \
+            -profile:v "$profile" -qp 16 -g 10 \
+            -f mobiclip_mo -y "$tencoded" || return
+        second_pos=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+            -select_streams v:0 -show_entries packet=pos -of csv=p=0 \
+            "$tencoded" | sed -n '2p') || return
+        set -- $(od -An -v -tu1 -j $((second_pos + 8)) -N 2 "$encoded")
+        [ "$#" -eq 2 ] || return 1
+
+        # Words are byte-swapped on disk, so $2 is the first logical byte.
+        # The masked prefixes include P/QP-delta, root mode 9, and child mode 8.
+        if [ "$profile" -eq 0 ]; then
+            [ "$2" -eq 93 ] && [ $(($1 & 128)) -eq 128 ] || {
+                echo "MO stream did not encode the nested 9->8 tree" >&2
+                return 1
+            }
+        else
+            [ "$2" -eq 95 ] && [ $(($1 & 128)) -eq 0 ] || {
+                echo "Moflex stream did not encode the nested 9->8 tree" >&2
+                return 1
+            }
+        fi
+
+        ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+            -frames:v 2 -pix_fmt yuv420p -c:v rawvideo \
+            -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+        [ "$(wc -c < "$decoded")" -eq $((width * height * 3)) ] || {
+            echo "profile $profile nested partition stream did not decode" >&2
+            return 1
+        }
+    done
+}
+
+
+# Give every 4x4 cell in the first macroblock a different exact integer
+# motion.  The selected tree must therefore pass through real 16x4 and 8x4
+# leaves before terminating at 4x4; an exact packet prefix locks the
+# size-specific MeMethod=1 paths rather than merely compiling them.
+mobiclip_moc5_deep_partitions(){
+    width=32
+    height=32
+    rate=25
+    source="${outdir}/${test}.source.yuv"
+    cleanfiles="$cleanfiles $source"
+    tsource=$(target_path "$source")
+
+    awk -v w="$width" -v h="$height" '
+        BEGIN {
+            for (y = 0; y < h; y++)
+                for (x = 0; x < w; x++)
+                    pixel[y * w + x] = 32 + ((x * 37 + y * 53 + x * y * 17) % 190);
+            for (i = 0; i < w * h; i++)
+                printf "%c", pixel[i];
+            for (i = 0; i < w * h / 2; i++)
+                printf "%c", 64;
+            for (y = 0; y < h; y++) {
+                for (x = 0; x < w; x++) {
+                    sx = x;
+                    sy = y;
+                    if (x < 16 && y < 16) {
+                        if (x < 8 && y >= 8) {
+                            if (y < 12)
+                                sy += 8;
+                            else if (x < 4)
+                                sy += 12;
+                            else
+                                sx += 8;
+                        }
+                        if (x >= 12)
+                            sx += 8;
+                    }
+                    printf "%c", pixel[sy * w + sx];
+                }
+            }
+            for (i = 0; i < w * h / 2; i++)
+                printf "%c", 64;
+        }
+    ' > "$source" || return
+
+    for profile in 0 1; do
+        encoded="${outdir}/${test}.${profile}.mo"
+        decoded="${outdir}/${test}.${profile}.yuv"
+        cleanfiles="$cleanfiles $encoded $decoded"
+        tencoded=$(target_path "$encoded")
+        tdecoded=$(target_path "$decoded")
+
+        ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+            -video_size "${width}x${height}" -framerate "$rate" \
+            -i "$tsource" -frames:v 2 -an -c:v mobiclip \
+            -profile:v "$profile" -qp 12 -g 10 \
+            -f mobiclip_mo -y "$tencoded" || return
+
+        second_pos=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+            -select_streams v:0 -show_entries packet=pos -of csv=p=0 \
+            "$tencoded" | sed -n '2p') || return
+        set -- $(od -An -v -tu1 -j $((second_pos + 8)) -N 12 "$encoded")
+        [ "$#" -eq 12 ] || return 1
+        if [ "$profile" -eq 0 ]; then
+            expected="46 83 130 169 207 13 10 4 130 96 8 230"
+        else
+            expected="93 66 4 195 16 19 134 65 46 8 4 129"
+        fi
+        [ "$1 $2 $3 $4 $5 $6 $7 $8 $9 ${10} ${11} ${12}" = "$expected" ] || {
+            echo "profile $profile did not encode the deep 4x4 tree" >&2
+            return 1
+        }
+
+        ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+            -frames:v 2 -pix_fmt yuv420p -c:v rawvideo \
+            -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+        [ "$(wc -c < "$decoded")" -eq $((width * height * 3)) ] || {
+            echo "profile $profile deep partition stream did not decode" >&2
+            return 1
+        }
+    done
+}
+
+
+# Exercise two partition details that a root-only recursion misses:
+# sibling leaves may refine in different orientations, and an explicitly
+# enabled mixed-reference tree may select a different reference per leaf.
+mobiclip_moc5_mixed_partitions(){
+    width=32
+    height=32
+    rate=25
+    tree_source="${outdir}/${test}.tree-source.yuv"
+    refs_source="${outdir}/${test}.refs-source.yuv"
+    cleanfiles="$cleanfiles $tree_source $refs_source"
+    ttree_source=$(target_path "$tree_source")
+    trefs_source=$(target_path "$refs_source")
+
+    # In the first macroblock, one branch benefits from a horizontal split
+    # while its sibling benefits from a vertical split.  Instrumenting the
+    # selected tree gives root 8, child 8, child 9 in both profiles.
+    awk -v w="$width" -v h="$height" '
+        BEGIN {
+            for (y = 0; y < h; y++)
+                for (x = 0; x < w; x++)
+                    pixel[y * w + x] = 32 + ((x * 37 + y * 53 + x * y * 17) % 95);
+            for (i = 0; i < w * h; i++)
+                printf "%c", pixel[i];
+            for (i = 0; i < w * h / 2; i++)
+                printf "%c", 64;
+            for (y = 0; y < h; y++) {
+                for (x = 0; x < w; x++) {
+                    sx = x;
+                    sy = y;
+                    if (x < 16 && y < 16) {
+                        if (x < 8 && y >= 8)
+                            sy += 8;
+                        if (x >= 12)
+                            sx += 8;
+                    }
+                    printf "%c", pixel[sy * w + sx];
+                }
+            }
+            for (i = 0; i < w * h / 2; i++)
+                printf "%c", 64;
+        }
+    ' > "$tree_source" || return
+
+    # Frame one is a four-pixel translation of frame zero.  Frame two takes
+    # the first macroblock's top half from ref1 and bottom half from ref2.
+    awk -v w="$width" -v h="$height" '
+        BEGIN {
+            for (y = 0; y < h; y++)
+                for (x = 0; x < w; x++)
+                    a[y * w + x] = 32 + ((x * 37 + y * 53 + x * y * 17) % 95);
+            for (y = 0; y < h; y++)
+                for (x = 0; x < w; x++)
+                    b[y * w + x] = a[y * w + (x + 4 < w ? x + 4 : w - 1)];
+            for (i = 0; i < w * h; i++)
+                printf "%c", a[i];
+            for (i = 0; i < w * h / 2; i++)
+                printf "%c", 64;
+            for (i = 0; i < w * h; i++)
+                printf "%c", b[i];
+            for (i = 0; i < w * h / 2; i++)
+                printf "%c", 64;
+            for (y = 0; y < h; y++) {
+                for (x = 0; x < w; x++) {
+                    value = b[y * w + x];
+                    if (x < 16 && y >= 8 && y < 16)
+                        value = a[y * w + x];
+                    printf "%c", value;
+                }
+            }
+            for (i = 0; i < w * h / 2; i++)
+                printf "%c", 64;
+        }
+    ' > "$refs_source" || return
+
+    for profile in 0 1; do
+        tree_encoded="${outdir}/${test}.tree-${profile}.mo"
+        tree_decoded="${outdir}/${test}.tree-${profile}.yuv"
+        refs_encoded="${outdir}/${test}.refs-${profile}.mo"
+        refs_decoded="${outdir}/${test}.refs-${profile}.yuv"
+        cleanfiles="$cleanfiles $tree_encoded $tree_decoded"
+        cleanfiles="$cleanfiles $refs_encoded $refs_decoded"
+        ttree_encoded=$(target_path "$tree_encoded")
+        ttree_decoded=$(target_path "$tree_decoded")
+        trefs_encoded=$(target_path "$refs_encoded")
+        trefs_decoded=$(target_path "$refs_decoded")
+
+        ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+            -video_size "${width}x${height}" -framerate "$rate" \
+            -i "$ttree_source" -frames:v 2 -an -c:v mobiclip \
+            -profile:v "$profile" -qp 12 -g 10 \
+            -f mobiclip_mo -y "$ttree_encoded" || return
+        second_pos=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+            -select_streams v:0 -show_entries packet=pos -of csv=p=0 \
+            "$ttree_encoded" | sed -n '2p') || return
+        set -- $(od -An -v -tu1 -j $((second_pos + 8)) -N 4 \
+            "$tree_encoded")
+        [ "$#" -eq 4 ] || return 1
+        if [ "$profile" -eq 0 ]; then
+            expected="11 83 128 224"
+        else
+            expected="7 66 10 130"
+        fi
+        [ "$1 $2 $3 $4" = "$expected" ] || {
+            echo "profile $profile did not encode the mixed 8->{8,9} tree" >&2
+            return 1
+        }
+        ffmpeg -auto_conversion_filters -i "$ttree_encoded" -map 0:v:0 \
+            -frames:v 2 -pix_fmt yuv420p -c:v rawvideo \
+            -fps_mode passthrough -f rawvideo -y "$ttree_decoded" || return
+
+        ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+            -video_size "${width}x${height}" -framerate "$rate" \
+            -i "$trefs_source" -frames:v 3 -an -c:v mobiclip \
+            -profile:v "$profile" -mixed_refs 1 -qp 12 -g 10 \
+            -f mobiclip_mo -y "$trefs_encoded" || return
+        third_pos=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+            -select_streams v:0 -show_entries packet=pos -of csv=p=0 \
+            "$trefs_encoded" | sed -n '3p') || return
+        set -- $(od -An -v -tu1 -j $((third_pos + 8)) -N 4 \
+            "$refs_encoded")
+        [ "$#" -eq 4 ] || return 1
+        if [ "$profile" -eq 0 ]; then
+            expected="63 81 0 252"
+        else
+            expected="31 69 0 240"
+        fi
+        [ "$1 $2 $3 $4" = "$expected" ] || {
+            echo "profile $profile did not encode ref1/ref2 split leaves" >&2
+            return 1
+        }
+        ffmpeg -auto_conversion_filters -i "$trefs_encoded" -map 0:v:0 \
+            -frames:v 3 -pix_fmt yuv420p -c:v rawvideo \
+            -fps_mode passthrough -f rawvideo -y "$trefs_decoded" || return
+
+        [ "$(wc -c < "$tree_decoded")" -eq $((width * height * 3)) ] &&
+        [ "$(wc -c < "$refs_decoded")" -eq $((width * height * 9 / 2)) ] || {
+            echo "profile $profile mixed partition streams did not decode" >&2
+            return 1
+        }
+    done
+}
+
+
 # A Wii player requires an authenticated pc certificate, but JD3/JD4 do not
 # require the optional per-file cc signature.  The muxer treats certificate
 # bytes as opaque; this deterministic dummy verifies the container topology.
@@ -300,13 +975,14 @@ mobiclip_moc5_certificate_only(){
     dd if=/dev/zero of="$certificate" bs=160 count=1 2>/dev/null || return
     ffmpeg -auto_conversion_filters -f lavfi \
         -i "testsrc2=size=${width}x${height}:rate=${rate}" \
-        -frames:v "$frames" -an -c:v mobiclip -qp 24 -g 1 \
+        -frames:v "$frames" -an -c:v mobiclip -qp 24 -g 4 \
         -moc5_certificate "$tcertificate" -f mobiclip_mo -y "$tencfile" || return
 
     sh "${base}/moc5check.sh" "$encfile" "$width" "$height" \
-        $((rate * 256)) "$frames" 1 0 1 || return
+        $((rate * 256)) "$frames" 4 0 1 || return
 }
 
+
 # Exercise MOC5's Wii stereo ADPCM path.  The sample count is deliberately an
 # exact number of 256-sample blocks, so any length mismatch is container loss,
 # not encoder flush padding.
@@ -315,7 +991,7 @@ mobiclip_moc5_adpcm_roundtrip(){
     height=80
     rate=25
     frames=12
-    gop=1
+    gop=4
     sample_rate=32000
     samples=15360
 
-- 
2.52.0


>From 92571d9e089feab41c85f8582d3328107c6be24a Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Fri, 31 Jul 2026 00:28:29 +0200
Subject: [PATCH 08/10] avcodec/mobiclipenc: add one-pass rate control

Add one-pass bitrate control while preserving direct fixed-quantizer operation.

Select frame quantizers from recent rate history and buffer state, retry oversized frames at higher quantizers, and support a hard maximum encoded-frame size. Allow scene changes and buffer pressure to trigger earlier keyframes when that improves the rate allocation.

Add coverage for common bitrate operation, frame-size retries, adaptive keyframes, and one-pass bitrate accuracy.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 doc/encoders.texi        |  20 +++-
 libavcodec/mobiclipenc.c | 242 ++++++++++++++++++++++++++++++++++++---
 tests/fate/mobiclip.mak  |  16 ++-
 tests/mobiclip-fate.sh   | 165 +++++++++++++++++++++++++-
 tests/moc5check.sh       |  52 ++++++++-
 5 files changed, 470 insertions(+), 25 deletions(-)

diff --git a/doc/encoders.texi b/doc/encoders.texi
index bd918bdbe6..6c02a85565 100644
--- a/doc/encoders.texi
+++ b/doc/encoders.texi
@@ -3575,15 +3575,25 @@ reference age. This is disabled by default.
 @item qp @var{integer}
 Set a fixed quantizer written directly to the MobiClip bitstream. The valid
 range is 12 to 63. Lower values retain more detail and usually produce larger
-files. When no quantizer is set, the encoder uses quantizer 40.
+files. Its default is -1, which enables one-pass rate control when a positive
+@option{bit_rate} is available. With neither an explicit quantizer nor a
+usable bitrate, the encoder falls back to fixed quantizer 40. An explicit
+quantizer disables one-pass rate control.
 
 @item profile @var{profile}
 Select @code{moc5} for the Wii MO bitstream profile or @code{moflex} for the
 flexible profile. The default is @code{moflex}.
 
+@item max_frame_size @var{integer}
+Set the maximum encoded MobiClip frame size in bytes. The encoder raises the
+frame quantizer until the packet fits. A value of 0, the default, disables the
+limit. This is an FFmpeg streaming-buffer convenience, useful for players with
+a fixed-size streaming buffer.
+
 @item g @var{integer}
-Set the maximum interval between keyframes. Forced keyframes may occur sooner
-and restart the interval.
+Set the maximum interval between keyframes. CBR retries and forced keyframes
+may occur sooner and restart the interval. Set a larger value at very low
+bitrates to avoid frequently discarding detail accumulated by P-frames.
 
 @item threads @var{integer}
 Set the encoder thread budget. A value of 0, the default, uses the number of
@@ -3592,6 +3602,10 @@ construct the current frame's references, so packet output remains bounded to
 one input frame.
 @end table
 
+With a positive @option{bit_rate} and no explicit @option{qp}, the encoder
+uses one-pass rate control. The standard @option{qmin} and @option{qmax}
+options constrain rate-control quantizers and default to 12 and 48.
+
 With @option{flags +psnr}, the encoder records component SSE in
 @code{AVCodecContext.error} and logs aggregate reconstruction SSE and PSNR.
 @section Microsoft RLE
diff --git a/libavcodec/mobiclipenc.c b/libavcodec/mobiclipenc.c
index c63474d7ac..aa8cfd9bda 100644
--- a/libavcodec/mobiclipenc.c
+++ b/libavcodec/mobiclipenc.c
@@ -29,6 +29,7 @@
 #include "libavutil/cpu.h"
 #include "libavutil/imgutils.h"
 #include "libavutil/internal.h"
+#include "libavutil/mathematics.h"
 #include "libavutil/mem.h"
 #include "libavutil/opt.h"
 #include "libavutil/pixelutils.h"
@@ -73,6 +74,7 @@ typedef struct MobiEncPartition {
 #define MOBI_MIN_QP 12
 #define MOBI_MAX_QP ((1 << MOBI_QP_BITS) - 1)
 #define MOBI_DEFAULT_QP 40
+#define MOBI_DEFAULT_RC_MAX_QP 48
 #define MOBI_DEFAULT_GOP_SIZE 0
 #define MOBI_MAX_REFERENCES 5
 #define MOBI_REFERENCE_SLOTS (MOBI_MAX_REFERENCES + 1)
@@ -85,6 +87,15 @@ typedef struct MobiEncPartition {
 #define MOBI_MAX_PARTITION_NODES (2 * MOBI_PARTITION_SEED_POSITIONS - 1)
 #define MOBI_LAMBDA_SCALE 0.035
 #define MOBI_QP_EXPONENT_STEP (M_LN2 / 3.0)
+#define MOBI_RATE_QP_SCALE 8.0f
+#define MOBI_INTRA_RATE_EXPONENT 0.09f
+#define MOBI_RC_REACTIVITY_SECONDS 8.0f
+#define MOBI_RC_SHORT_HISTORY_SECONDS 0.32f
+#define MOBI_RC_DEFAULT_BUFFER_BITS 128000
+#define MOBI_RC_INTRA_BOOST 0.40f
+#define MOBI_INTRA_RETRY_PERCENT 90
+#define MOBI_RC_INITIAL_SLOPE 7.0f
+#define MOBI_RC_INITIAL_OFFSET 22.0f
 
 typedef struct MobiPartitionSeed {
     int x, y;
@@ -128,6 +139,14 @@ typedef struct MobiIntraMacroblock {
     int cost;
 } MobiIntraMacroblock;
 
+typedef struct MobiRateHistory {
+    float *samples;
+    float sum;
+    int size;
+    int count;
+    int pos;
+} MobiRateHistory;
+
 typedef struct MobiClipEncContext {
     const AVClass *class;
     AVFrame *reference[MOBI_REFERENCE_SLOTS];
@@ -153,9 +172,18 @@ typedef struct MobiClipEncContext {
     int qdiv;
     int qrem;
     int moflex;
+    int max_frame_size;
+    int rc_enabled;
+    int common_cbr;
     int previous_qp;
     int64_t frame_count;
     int64_t last_key_frame;
+    MobiRateHistory rc_history[2];
+    float rc_target_bits_per_frame;
+    float rc_next_qp;
+    int64_t rc_debt;
+    int rc_reactivity_frames;
+    int rc_buffer_size;
     long double total_sse;
     int qtab[16];
     int16_t qmul4[16];
@@ -377,6 +405,116 @@ static const int mobi_zero_threshold4[] = {
    39963, 50517,
 };
 
+static int rate_history_init(MobiRateHistory *history, int size)
+{
+    history->size = FFMAX(size, 1);
+    history->samples = av_calloc(history->size, sizeof(*history->samples));
+    return history->samples ? 0 : AVERROR(ENOMEM);
+}
+
+static void rate_history_add(MobiRateHistory *history, float sample)
+{
+    if (history->count == history->size)
+        history->sum -= history->samples[history->pos];
+    else
+        history->count++;
+    history->samples[history->pos] = sample;
+    history->sum += sample;
+    if (++history->pos == history->size)
+        history->pos = 0;
+}
+
+static int setup_common_cbr(AVCodecContext *avctx)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+    AVRational rate;
+    float fps;
+    int history_size;
+    int ret;
+
+    if (avctx->framerate.num > 0 && avctx->framerate.den > 0)
+        rate = avctx->framerate;
+    else if (avctx->time_base.num > 0 && avctx->time_base.den > 0)
+        rate = av_inv_q(avctx->time_base);
+    else
+        return AVERROR(EINVAL);
+    fps = (float)rate.num / (float)rate.den;
+    s->rc_target_bits_per_frame = (float)avctx->bit_rate / fps;
+    if (!isfinite(fps) || fps <= 0.0f ||
+        !isfinite(s->rc_target_bits_per_frame) ||
+        s->rc_target_bits_per_frame <= 0.0f ||
+        fps > INT_MAX / MOBI_RC_REACTIVITY_SECONDS) {
+        av_log(avctx, AV_LOG_ERROR,
+               "Invalid frame rate or bitrate for MobiClip rate control\n");
+        return AVERROR(EINVAL);
+    }
+    s->rc_reactivity_frames =
+        FFMAX((int)(fps * MOBI_RC_REACTIVITY_SECONDS), 1);
+    s->rc_buffer_size = avctx->rc_buffer_size > 0 ?
+                        avctx->rc_buffer_size : MOBI_RC_DEFAULT_BUFFER_BITS;
+    history_size = (int)(fps * MOBI_RC_SHORT_HISTORY_SECONDS);
+    for (int type = 0; type < 2; type++) {
+        ret = rate_history_init(&s->rc_history[type], history_size);
+        if (ret < 0) {
+            for (int free_type = 0; free_type < 2; free_type++)
+                av_freep(&s->rc_history[free_type].samples);
+            return ret;
+        }
+    }
+    s->rc_next_qp =
+        MOBI_RC_INITIAL_SLOPE *
+        logf(((float)avctx->width * (float)avctx->height) /
+             s->rc_target_bits_per_frame) + MOBI_RC_INITIAL_OFFSET;
+    s->rc_next_qp -=
+        MOBI_RATE_QP_SCALE * log1pf(MOBI_RC_INTRA_BOOST);
+    return 0;
+}
+
+static int common_cbr_qp(const AVCodecContext *avctx,
+                         MobiClipEncContext *s, int key)
+{
+    float qp;
+
+    if (key)
+        s->rc_next_qp -=
+            MOBI_RATE_QP_SCALE * log1pf(MOBI_RC_INTRA_BOOST);
+    qp = s->rc_next_qp;
+    return av_clip(qp < 0.0f ? (int)(qp - 0.5f) : (int)(qp + 0.5f),
+                   avctx->qmin, avctx->qmax);
+}
+
+static void finish_common_cbr(MobiClipEncContext *s, int key, int64_t bits)
+{
+    const int type = key ? 0 : 1;
+    const float exponent =
+        key ? MOBI_INTRA_RATE_EXPONENT : 1.0f / MOBI_RATE_QP_SCALE;
+    const int64_t max_debt = s->rc_buffer_size / 2;
+    int64_t target_bits;
+    MobiRateHistory *short_history;
+    float effective_target;
+    float mean;
+    float sample;
+
+    sample = (float)bits * expf((float)s->qp * exponent);
+    rate_history_add(&s->rc_history[type], sample);
+
+    short_history = s->rc_history[1].count ?
+                    &s->rc_history[1] : &s->rc_history[0];
+    mean = short_history->sum / (float)short_history->count;
+
+    target_bits = s->rc_target_bits_per_frame >= (float)INT64_MAX ?
+                  INT64_MAX : (int64_t)s->rc_target_bits_per_frame;
+    s->rc_debt = av_sat_add64(s->rc_debt,
+                              av_sat_sub64(target_bits, bits));
+    s->rc_debt = av_clip64(s->rc_debt, -max_debt, max_debt);
+    effective_target =
+        ((float)s->rc_reactivity_frames * s->rc_target_bits_per_frame +
+         (float)s->rc_debt) / (float)s->rc_reactivity_frames;
+    effective_target = FFMAX(effective_target, 1.0f);
+    s->rc_next_qp =
+        -MOBI_RATE_QP_SCALE * logf(effective_target / mean);
+}
+
 static int find_mask(const uint8_t *table, int count, unsigned mask)
 {
     for (int i = 0; i < count; i++)
@@ -3417,12 +3555,14 @@ static int refine_inter_partitions(MobiClipEncContext *s,
 }
 
 static int encode_inter(AVCodecContext *avctx, PutBitContext *pb,
-                        const AVFrame *src)
+                        const AVFrame *src,
+                        int *intra_count, int *inter_count)
 {
     MobiClipEncContext *s = avctx->priv_data;
     int mb_width = avctx->width / 16;
     int available_refs = FFMIN(avctx->refs, s->frames_since_key);
 
+    *intra_count = *inter_count = 0;
     put_bits(pb, 1, 0);
     set_se_golomb(pb, s->qp - s->previous_qp);
     memset(s->motion, 0, (mb_width + 3) * sizeof(*s->motion));
@@ -3583,6 +3723,10 @@ static int encode_inter(AVCodecContext *avctx, PutBitContext *pb,
                                  selected.mode == 6 || selected.mode == 7 ?
                                  &selected_intra : NULL,
                                  pred_x, pred_y, NULL);
+            if (selected.mode == 6 || selected.mode == 7)
+                (*intra_count)++;
+            else
+                (*inter_count)++;
         }
     }
     return 0;
@@ -3602,9 +3746,9 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
                "MobiClip width and height must be multiples of 16\n");
         return AVERROR(EINVAL);
     }
-    if (s->qp < MOBI_MIN_QP || s->qp > MOBI_MAX_QP) {
+    if (s->qp >= 0 && s->qp < MOBI_MIN_QP) {
         av_log(avctx, AV_LOG_ERROR,
-               "MobiClip qp must be in the range 12..63\n");
+               "Direct MobiClip qp must be -1 or in the range 12..63\n");
         return AVERROR(EINVAL);
     }
     if (avctx->refs < 1 || avctx->refs > MOBI_MAX_REFERENCES) {
@@ -3612,6 +3756,12 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
                "MobiClip reference count must be in the range 1..5\n");
         return AVERROR(EINVAL);
     }
+    if (avctx->qmin < MOBI_MIN_QP || avctx->qmax > MOBI_MAX_QP ||
+        avctx->qmin > avctx->qmax) {
+        av_log(avctx, AV_LOG_ERROR,
+               "MobiClip rate-control QP range must be within 12..63\n");
+        return AVERROR(EINVAL);
+    }
     if (avctx->profile != AV_PROFILE_MOBICLIP_MOC5 &&
         avctx->profile != AV_PROFILE_MOBICLIP_MOFLEX) {
         av_log(avctx, AV_LOG_ERROR,
@@ -3623,7 +3773,7 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
         return ret;
     if (pass_flags) {
         av_log(avctx, AV_LOG_ERROR,
-               "MobiClip rate control is not supported\n");
+               "MobiClip two-pass rate control is not supported\n");
         return AVERROR(EINVAL);
     }
     if (avctx->gop_size < 0)
@@ -3682,6 +3832,13 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
     if ((ret = av_frame_get_buffer(s->recon, 32)) < 0)
         return ret;
 
+    if (s->qp < 0 && avctx->bit_rate > 0) {
+        s->rc_enabled = s->common_cbr = 1;
+        if ((ret = setup_common_cbr(avctx)) < 0)
+            return ret;
+    }
+    if (s->qp < 0)
+        s->qp = MOBI_DEFAULT_QP;
     mobi_set_qp(s, s->qp);
 
     setup_qtable(s);
@@ -3723,12 +3880,15 @@ static av_cold int mobiclip_encode_close(AVCodecContext *avctx)
     av_frame_free(&s->recon);
     av_frame_free(&s->input);
     av_freep(&s->motion);
+    for (int type = 0; type < 2; type++)
+        av_freep(&s->rc_history[type].samples);
     return 0;
 }
 
 static int encode_frame_attempt(AVCodecContext *avctx, AVPacket *pkt,
                                 int max_size, const AVFrame *frame, int key,
-                                int *size)
+                                int *size,
+                                int *intra_count, int *inter_count)
 {
     MobiClipEncContext *s = avctx->priv_data;
     PutBitContext pb;
@@ -3739,10 +3899,13 @@ static int encode_frame_attempt(AVCodecContext *avctx, AVPacket *pkt,
         return ret;
 
     init_put_bits(&pb, pkt->data, max_size);
-    if (key)
+    if (key) {
         ret = encode_intra(avctx, &pb, frame);
-    else
-        ret = encode_inter(avctx, &pb, frame);
+        *intra_count = avctx->width / 16 * (avctx->height / 16);
+        *inter_count = 0;
+    } else {
+        ret = encode_inter(avctx, &pb, frame, intra_count, inter_count);
+    }
     if (ret < 0)
         return ret;
     flush_put_bits(&pb);
@@ -3756,24 +3919,65 @@ static int mobiclip_encode_frame(AVCodecContext *avctx, AVPacket *pkt,
     MobiClipEncContext *s = avctx->priv_data;
     int64_t samples = avctx->width * (int64_t)avctx->height * 3 / 2;
     int64_t max_size = samples * 4 + avctx->width * (int64_t)avctx->height / 16 + 64;
-    int key = !s->frame_count ||
-              (frame && frame->pict_type == AV_PICTURE_TYPE_I) ||
-              (avctx->gop_size > 0 && s->last_key_frame >= 0 &&
-               s->frame_count - s->last_key_frame >= avctx->gop_size);
+    int mandatory_key = !s->frame_count ||
+                        (frame && frame->pict_type == AV_PICTURE_TYPE_I) ||
+                        (avctx->gop_size > 0 && s->last_key_frame >= 0 &&
+                         s->frame_count - s->last_key_frame >= avctx->gop_size);
+    int key = mandatory_key;
     int64_t frame_bits;
+    int intra_count, inter_count;
     int ret, size;
 
     if (!frame)
         return 0;
+    if (s->common_cbr)
+        mobi_set_qp(s, common_cbr_qp(avctx, s, key));
     if (max_size > INT_MAX)
         return AVERROR(ERANGE);
     if ((ret = ff_get_encode_buffer(avctx, pkt, max_size, 0)) < 0)
         return ret;
 
     if ((ret = encode_frame_attempt(avctx, pkt, max_size, frame, key,
-                                    &size)) < 0)
+                                    &size, &intra_count,
+                                    &inter_count)) < 0)
         return ret;
 
+    /*
+     * Retry a P picture as I when its intra-macroblock share reaches the
+     * configured threshold.  The discarded attempt has not reached any rate,
+     * reference, statistics, or frame-index commit point.
+     */
+    if (!key && s->common_cbr &&
+        (int64_t)inter_count * MOBI_INTRA_RETRY_PERCENT <=
+        (int64_t)(100 - MOBI_INTRA_RETRY_PERCENT) * intra_count) {
+        key = 1;
+        s->rc_next_qp -=
+            MOBI_RATE_QP_SCALE * log1pf(MOBI_RC_INTRA_BOOST);
+        if ((ret = encode_frame_attempt(avctx, pkt, max_size, frame, key,
+                                        &size, &intra_count,
+                                        &inter_count)) < 0)
+            return ret;
+    }
+
+    if (s->max_frame_size && size > s->max_frame_size) {
+        int max_qp = s->rc_enabled ? avctx->qmax : MOBI_MAX_QP;
+
+        while (size > s->max_frame_size && s->qp < max_qp) {
+            mobi_set_qp(s, s->qp + 1);
+            if ((ret = encode_frame_attempt(avctx, pkt, max_size, frame, key,
+                                            &size, &intra_count,
+                                            &inter_count)) < 0)
+                return ret;
+        }
+        if (size > s->max_frame_size) {
+            av_log(avctx, AV_LOG_ERROR,
+                   "MobiClip frame %" PRId64 " requires %d bytes at qp=%d, "
+                   "exceeding max_frame_size=%d\n",
+                   s->frame_count, size, s->qp, s->max_frame_size);
+            return AVERROR(ERANGE);
+        }
+    }
+
     /* Packets are stored with the two bytes of every 16-bit word swapped. */
     if (size & 1)
         pkt->data[size++] = 0;
@@ -3782,6 +3986,9 @@ static int mobiclip_encode_frame(AVCodecContext *avctx, AVPacket *pkt,
     pkt->size = size;
     frame_bits = 8LL * size;
 
+    if (s->common_cbr)
+        finish_common_cbr(s, key, frame_bits);
+
     if (avctx->flags & AV_CODEC_FLAG_PSNR) {
         uint64_t error[3];
         uint64_t distortion = frame_sse(avctx, frame, s->recon, error);
@@ -3874,9 +4081,10 @@ static const AVOption mobiclip_options[] = {
                             VIDEO, AV_PROFILE_MOBICLIP_MOFLEX)
     { "mixed_refs", "allow each split partition to select its reference age",
       OFFSET(mixed_refs), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, VE },
-    { "qp", "set the MobiClip bitstream quantizer",
-      OFFSET(qp), AV_OPT_TYPE_INT, { .i64 = MOBI_DEFAULT_QP },
-      MOBI_MIN_QP, MOBI_MAX_QP, VE },
+    { "qp", "set the direct MobiClip bitstream quantizer (-1 uses bitrate control)",
+      OFFSET(qp), AV_OPT_TYPE_INT, { .i64 = -1 }, -1, MOBI_MAX_QP, VE },
+    { "max_frame_size", "set the maximum encoded frame size in bytes (0 disables)",
+      OFFSET(max_frame_size), AV_OPT_TYPE_INT, { .i64 = 0 }, 0, INT_MAX, VE },
     { NULL },
 };
 
@@ -3888,6 +4096,8 @@ static const AVClass mobiclip_encoder_class = {
 };
 
 static const FFCodecDefault mobiclip_defaults[] = {
+    { "qmin", AV_STRINGIFY(MOBI_MIN_QP) },
+    { "qmax", AV_STRINGIFY(MOBI_DEFAULT_RC_MAX_QP) },
     { "refs", AV_STRINGIFY(MOBI_MAX_REFERENCES) },
     { "profile", "moflex" },
     { "g", AV_STRINGIFY(MOBI_DEFAULT_GOP_SIZE) },
diff --git a/tests/fate/mobiclip.mak b/tests/fate/mobiclip.mak
index cd1b200148..02d2e773d8 100644
--- a/tests/fate/mobiclip.mak
+++ b/tests/fate/mobiclip.mak
@@ -18,7 +18,9 @@ FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)) += \
     fate-mobiclip-moc5-nested-partitions \
     fate-mobiclip-moc5-deep-partitions \
     fate-mobiclip-moc5-mixed-partitions \
-    fate-mobiclip-moc5-certificate-only
+    fate-mobiclip-moc5-certificate-only \
+    fate-mobiclip-moc5-common-cbr \
+    fate-mobiclip-moc5-onepass-cbr
 fate-mobiclip-moc5-roundtrip: CMD = mobiclip_moc5_roundtrip 128 80 25 12 4 20
 fate-mobiclip-moc5-roundtrip: CMP = null
 fate-mobiclip-moc5-intra-modes: CMD = mobiclip_moc5_intra_modes
@@ -41,6 +43,10 @@ fate-mobiclip-moc5-mixed-partitions: CMD = mobiclip_moc5_mixed_partitions
 fate-mobiclip-moc5-mixed-partitions: CMP = null
 fate-mobiclip-moc5-certificate-only: CMD = mobiclip_moc5_certificate_only
 fate-mobiclip-moc5-certificate-only: CMP = null
+fate-mobiclip-moc5-common-cbr: CMD = mobiclip_moc5_common_cbr
+fate-mobiclip-moc5-common-cbr: CMP = null
+fate-mobiclip-moc5-onepass-cbr: CMD = mobiclip_moc5_onepass_cbr
+fate-mobiclip-moc5-onepass-cbr: CMP = null
 
 FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)) += \
     fate-mobiclip-moc5-reference-drift
@@ -51,13 +57,19 @@ fate-mobiclip-moc5-reference-drift: CMP = null
 FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS) COLOR_FILTER) += \
     fate-mobiclip-moc5-direct-motion \
     fate-mobiclip-moc5-multiref \
-    fate-mobiclip-moc5-p-intra
+    fate-mobiclip-moc5-p-intra \
+    fate-mobiclip-moc5-scene-retry \
+    fate-mobiclip-moc5-adaptive-keyframes
 fate-mobiclip-moc5-direct-motion: CMD = mobiclip_moc5_direct_motion
 fate-mobiclip-moc5-direct-motion: CMP = null
 fate-mobiclip-moc5-multiref: CMD = mobiclip_moc5_multiref
 fate-mobiclip-moc5-multiref: CMP = null
 fate-mobiclip-moc5-p-intra: CMD = mobiclip_moc5_p_intra
 fate-mobiclip-moc5-p-intra: CMP = null
+fate-mobiclip-moc5-scene-retry: CMD = mobiclip_moc5_scene_retry
+fate-mobiclip-moc5-scene-retry: CMP = null
+fate-mobiclip-moc5-adaptive-keyframes: CMD = mobiclip_moc5_adaptive_keyframes
+fate-mobiclip-moc5-adaptive-keyframes: CMP = null
 
 MOBICLIP_MOC5_ADPCM_DEPS = $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)          \
                            SINE_FILTER ARESAMPLE_FILTER             \
diff --git a/tests/mobiclip-fate.sh b/tests/mobiclip-fate.sh
index ce44b95658..cce4e345dd 100644
--- a/tests/mobiclip-fate.sh
+++ b/tests/mobiclip-fate.sh
@@ -248,6 +248,54 @@ mobiclip_moc5_zero_mask_intra_split(){
     }
 }
 
+# Exercise a one-pass CBR scene retry. The dissimilar second frame must be
+# accepted as an I frame rather than leaving the discarded P attempt visible.
+mobiclip_moc5_scene_retry(){
+    width=128
+    height=16
+    rate=25
+    frames=2
+    bitrate=50000
+    black="${outdir}/${test}.black.yuv"
+    white="${outdir}/${test}.white.yuv"
+    source="${outdir}/${test}.source.yuv"
+    encoded="${outdir}/${test}.mo"
+    cleanfiles="$cleanfiles $black $white $source $encoded"
+    tblack=$(target_path "$black")
+    twhite=$(target_path "$white")
+    tsource=$(target_path "$source")
+    tencoded=$(target_path "$encoded")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "color=c=black:size=${width}x${height}:rate=${rate}" \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tblack" || return
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "color=c=white:size=${width}x${height}:rate=${rate}" \
+        -frames:v 1 -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$twhite" || return
+    cat "$black" "$white" > "$source" || return
+
+    ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+        -video_size "${width}x${height}" -framerate "$rate" \
+        -i "$tsource" -frames:v "$frames" -an -c:v mobiclip \
+        -profile:v moflex -b:v "$bitrate" -g 10 \
+        -f mobiclip_mo -y "$tencoded" || return
+
+    sh "${base}/moc5check.sh" "$encoded" "$width" "$height" \
+        $((rate * 256)) "$frames" 10 || return
+    second_flags=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=flags -of csv=p=0 \
+        "$tencoded" | sed -n '2p') || return
+    case "$second_flags" in
+        *K*) ;;
+        *)
+            echo "accepted scene retry packet is not marked key" >&2
+            return 1
+            ;;
+    esac
+}
+
 
 # Exercise the I-picture mode-6/mode-7 selector on a small textured image.
 # The exact packet size also locks hierarchical 16x16 -> 8x8 ->
@@ -979,7 +1027,122 @@ mobiclip_moc5_certificate_only(){
         -moc5_certificate "$tcertificate" -f mobiclip_mo -y "$tencfile" || return
 
     sh "${base}/moc5check.sh" "$encfile" "$width" "$height" \
-        $((rate * 256)) "$frames" 4 0 1 || return
+        $((rate * 256)) "$frames" 4 0 0 1 || return
+}
+
+
+# Verify that an explicitly forced keyframe restarts the maximum GOP interval.
+mobiclip_moc5_adaptive_keyframes(){
+    width=128
+    height=80
+    rate=25
+    black_frames=5
+    white_frames=13
+    frames=$((black_frames + white_frames))
+    gop=10
+
+    black="${outdir}/${test}.black.yuv"
+    white="${outdir}/${test}.white.yuv"
+    source="${outdir}/${test}.source.yuv"
+    encoded="${outdir}/${test}.mo"
+    cleanfiles="$cleanfiles $black $white $source $encoded"
+    tsource=$(target_path "$source")
+    tencoded=$(target_path "$encoded")
+
+    tblack=$(target_path "$black")
+    twhite=$(target_path "$white")
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "color=c=black:size=${width}x${height}:rate=${rate}" \
+        -frames:v "$black_frames" -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tblack" || return
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "color=c=white:size=${width}x${height}:rate=${rate}" \
+        -frames:v "$white_frames" -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$twhite" || return
+    cat "$black" "$white" > "$source" || return
+
+    ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+        -video_size "${width}x${height}" -framerate "$rate" -i "$tsource" \
+        -frames:v "$frames" -an -c:v mobiclip -threads:v 1 -qp 24 -g "$gop" \
+        -force_key_frames "expr:eq(n,7)" \
+        -f mobiclip_mo -y "$tencoded" || return
+
+    sh "${base}/moc5check.sh" "$encoded" "$width" "$height" \
+        $((rate * 256)) "$frames" "$gop" || return
+
+    keyframes=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=pts,flags -of csv=p=0 \
+        "$tencoded" | awk -F, '$0 ~ /K/ { print $1 }' | paste -sd, -) || return
+    [ "$keyframes" = "0,7,17" ] || {
+        echo "adaptive/forced keyframes are '$keyframes', expected '0,7,17'" >&2
+        return 1
+    }
+}
+
+# Exercise the one-pass common-CBR controller across ordinary and forced-intra
+# frames. The stream must change quantizer and preserve the forced keyframe.
+mobiclip_moc5_common_cbr(){
+    width=128
+    height=80
+    rate=25
+    frames=12
+    bitrate=100000
+
+    encoded="${outdir}/${test}.mo"
+    cleanfiles="$cleanfiles $encoded"
+    tencoded=$(target_path "$encoded")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -an -c:v mobiclip -b:v "$bitrate" -g 24 \
+        -force_key_frames "expr:eq(n,2)" \
+        -f mobiclip_mo -y "$tencoded" || return
+
+    sh "${base}/moc5check.sh" "$encoded" "$width" "$height" \
+        $((rate * 256)) "$frames" 24 0 1 || return
+    third_flags=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=flags -of csv=p=0 \
+        "$tencoded" | sed -n '3p') || return
+    case "$third_flags" in
+        *K*) ;;
+        *)
+            echo "forced common-CBR packet is not marked key" >&2
+            return 1
+            ;;
+    esac
+}
+
+
+# Exercise the same common-CBR path without enabling a pass flag.
+# This is the ordinary -b:v interface: no explicit QP and no statistics file.
+mobiclip_moc5_onepass_cbr(){
+    width=128
+    height=80
+    rate=25
+    frames=12
+    bitrate=100000
+
+    encoded="${outdir}/${test}.mo"
+    decoded="${outdir}/${test}.decoded.yuv"
+    cleanfiles="$cleanfiles $encoded $decoded"
+    tencoded=$(target_path "$encoded")
+    tdecoded=$(target_path "$decoded")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -an -c:v mobiclip -b:v "$bitrate" -g 24 \
+        -force_key_frames "expr:eq(n,2)" \
+        -f mobiclip_mo -y "$tencoded" || return
+
+    sh "${base}/moc5check.sh" "$encoded" "$width" "$height" \
+        $((rate * 256)) "$frames" 24 0 1 || return
+    ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+    [ "$(wc -c < "$decoded")" -eq $((width * height * 3 / 2 * frames)) ] || {
+        echo "one-pass CBR output did not decode every frame" >&2
+        return 1
+    }
 }
 
 
diff --git a/tests/moc5check.sh b/tests/moc5check.sh
index 813b94ecbb..e8d9412dcd 100644
--- a/tests/moc5check.sh
+++ b/tests/moc5check.sh
@@ -23,7 +23,7 @@
 # round-trip test capable of catching a matching muxer/demuxer bug.
 #
 # Usage: moc5check.sh file width height fps_fixed frames [gop] [compact_ki]
-#                     [certificate_mode]
+#                     [minimum_qp_changes] [certificate_mode]
 # certificate_mode: 0 = no certificate, 1 = pc certificate only
 
 set -eu
@@ -35,7 +35,8 @@ expected_fps_fixed=$4
 expected_frames=$5
 expected_gop=${6:-0}
 expected_compact_ki=${7:-0}
-expected_certificate_mode=${8:-0}
+expected_qp_changes=${8:-0}
+expected_certificate_mode=${9:-0}
 
 fail()
 {
@@ -227,6 +228,8 @@ offset=$data_start
 frame=0
 video_bytes=0
 audio_bytes=0
+current_qp=-1
+qp_changes=0
 while [ "$frame" -lt "$expected_frames" ]; do
     [ $((offset + 8)) -le "$file_size" ] || fail "truncated chunk header for frame $frame"
     chunk_size=$(read_u32le "$file" "$offset")
@@ -238,6 +241,47 @@ while [ "$frame" -lt "$expected_frames" ]; do
     [ "$video_size" -le $((chunk_size - 4)) ] ||
         fail "frame $frame video size exceeds its chunk"
 
+    if [ "$expected_qp_changes" -gt 0 ]; then
+        [ "$video_size" -ge 4 ] || fail "frame $frame video header is too short"
+        set -- $(read_bytes "$file" $((offset + 8)) 4)
+        [ "$#" -eq 4 ] || fail "short video header at frame $frame"
+        # Video packets swap the bytes in every 16-bit word.  Restore the
+        # first 32 bits so the independent checker can parse the frame QP.
+        header=$((($2 << 24) | ($1 << 16) | ($4 << 8) | $3))
+        intra=$(((header >> 31) & 1))
+        if [ "$intra" -eq 1 ]; then
+            next_qp=$(((header >> 23) & 63))
+        else
+            bit=30
+            zeros=0
+            while [ "$bit" -ge 0 ] && [ $(((header >> bit) & 1)) -eq 0 ]; do
+                zeros=$((zeros + 1))
+                bit=$((bit - 1))
+            done
+            [ "$bit" -ge 0 ] || fail "unterminated QP delta at frame $frame"
+            bit=$((bit - 1))
+            suffix=0
+            if [ "$zeros" -gt 0 ]; then
+                shift=$((bit - zeros + 1))
+                [ "$shift" -ge 0 ] || fail "oversized QP delta at frame $frame"
+                suffix=$(((header >> shift) & ((1 << zeros) - 1)))
+            fi
+            code=$(((1 << zeros) - 1 + suffix))
+            if [ $((code & 1)) -eq 1 ]; then
+                delta=$(((code + 1) / 2))
+            else
+                delta=$((-(code / 2)))
+            fi
+            [ "$current_qp" -ge 0 ] || fail "P-frame before initial QP"
+            next_qp=$((current_qp + delta))
+        fi
+        [ "$next_qp" -ge 12 ] && [ "$next_qp" -le 63 ] ||
+            fail "frame $frame has out-of-range QP $next_qp"
+        if [ "$current_qp" -ge 0 ] && [ "$next_qp" -ne "$current_qp" ]; then
+            qp_changes=$((qp_changes + 1))
+        fi
+        current_qp=$next_qp
+    fi
 
     raw_end=$((offset + chunk_size))
     [ "$raw_end" -le "$file_size" ] || fail "frame $frame chunk exceeds file"
@@ -265,6 +309,8 @@ done
     fail "expected a four-byte terminal chunk, found $((file_size - offset)) bytes"
 [ "$(read_u32le "$file" "$offset")" -eq 0 ] ||
     fail "terminal chunk is not zero"
+[ "$qp_changes" -ge "$expected_qp_changes" ] ||
+    fail "only $qp_changes QP changes, expected at least $expected_qp_changes"
 
 echo "moc5: frames=$frame keys=$key_count video_bytes=$video_bytes" \
-     "audio_bytes=$audio_bytes size=$file_size"
+     "audio_bytes=$audio_bytes qp_changes=$qp_changes size=$file_size"
-- 
2.52.0


>From 15f04c996ead6ebfc4859b43d1218443e8a9b772 Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Fri, 31 Jul 2026 00:36:56 +0200
Subject: [PATCH 09/10] avcodec/mobiclipenc: add two-pass rate control

Implement FFmpeg's standard two-pass interface for the MobiClip encoder.

Pass one emits a usable stream together with per-frame complexity, frame-type, quantizer, and size records. Pass two validates those records, plans quantizers against the global bitrate and reservoir, and preserves pass-one frame-type decisions.

Add coverage for bitrate accuracy, iterative planning, deterministic plan reuse, and the complete six-bit quantizer range.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 doc/encoders.texi        |  28 ++-
 libavcodec/mobiclipenc.c | 377 ++++++++++++++++++++++++++++++++++++++-
 tests/fate/mobiclip.mak  |  29 ++-
 tests/mobiclip-fate.sh   | 271 ++++++++++++++++++++++++----
 tests/moc5check.sh       |  15 +-
 5 files changed, 661 insertions(+), 59 deletions(-)

diff --git a/doc/encoders.texi b/doc/encoders.texi
index 6c02a85565..4d7f549d5f 100644
--- a/doc/encoders.texi
+++ b/doc/encoders.texi
@@ -3575,10 +3575,11 @@ reference age. This is disabled by default.
 @item qp @var{integer}
 Set a fixed quantizer written directly to the MobiClip bitstream. The valid
 range is 12 to 63. Lower values retain more detail and usually produce larger
-files. Its default is -1, which enables one-pass rate control when a positive
+files. Its default is -1, which enables rate control when a positive
 @option{bit_rate} is available. With neither an explicit quantizer nor a
 usable bitrate, the encoder falls back to fixed quantizer 40. An explicit
-quantizer disables one-pass rate control.
+quantizer disables one-pass rate control; pass flags still select their
+corresponding pass modes.
 
 @item profile @var{profile}
 Select @code{moc5} for the Wii MO bitstream profile or @code{moflex} for the
@@ -3586,9 +3587,10 @@ flexible profile. The default is @code{moflex}.
 
 @item max_frame_size @var{integer}
 Set the maximum encoded MobiClip frame size in bytes. The encoder raises the
-frame quantizer until the packet fits. A value of 0, the default, disables the
-limit. This is an FFmpeg streaming-buffer convenience, useful for players with
-a fixed-size streaming buffer.
+frame quantizer until the packet fits, and two-pass rate control reserves the
+remaining budget for frames that are below the limit. A value of 0, the
+default, disables the limit. This is an FFmpeg streaming-buffer convenience,
+useful for players with a fixed-size streaming buffer.
 
 @item g @var{integer}
 Set the maximum interval between keyframes. CBR retries and forced keyframes
@@ -3602,12 +3604,22 @@ construct the current frame's references, so packet output remains bounded to
 one input frame.
 @end table
 
-With a positive @option{bit_rate} and no explicit @option{qp}, the encoder
-uses one-pass rate control. The standard @option{qmin} and @option{qmax}
-options constrain rate-control quantizers and default to 12 and 48.
+The encoder also supports FFmpeg's normal two-pass interface when a positive
+@option{bit_rate} is set. Pass 1 emits a usable stream and per-frame text
+statistics through @option{stats_out}. Pass 2 consumes the corresponding
+@option{stats_in}. Frame-type decisions made during pass 1 are reused during
+pass 2. The standard @option{qmin} and @option{qmax} options constrain
+rate-control quantizers and default to 12 and 48.
+@option{rc_buffer_size} and @option{rc_init_occupancy} configure the pass-2
+rate-control reservoir.
 
 With @option{flags +psnr}, the encoder records component SSE in
 @code{AVCodecContext.error} and logs aggregate reconstruction SSE and PSNR.
+
+Without a positive bit rate, pass flags are ignored after a warning; an
+explicit @option{qp} is used directly, or quantizer 40 is used as the
+fallback.
+
 @section Microsoft RLE
 
 Microsoft RLE aka MSRLE encoder.
diff --git a/libavcodec/mobiclipenc.c b/libavcodec/mobiclipenc.c
index aa8cfd9bda..49672ea6fe 100644
--- a/libavcodec/mobiclipenc.c
+++ b/libavcodec/mobiclipenc.c
@@ -20,10 +20,12 @@
  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
  */
 
+#include <float.h>
 #include <inttypes.h>
 #include <math.h>
 #include <stddef.h>
 #include <stdint.h>
+#include <stdio.h>
 
 #include "libavutil/common.h"
 #include "libavutil/cpu.h"
@@ -79,6 +81,7 @@ typedef struct MobiEncPartition {
 #define MOBI_MAX_REFERENCES 5
 #define MOBI_REFERENCE_SLOTS (MOBI_MAX_REFERENCES + 1)
 #define MOBI_INTER_UNITS (4 + 2)
+#define MOBI_PASS_STATS_SIZE 80
 #define MOBI_RDO_BUFFER_SIZE 4096
 #define MOBI_RL_COST_FULL_ESCAPE 63
 #define MOBI_RL_COST_MAGNITUDES (MOBI_RL_COST_FULL_ESCAPE + 1)
@@ -96,6 +99,11 @@ typedef struct MobiEncPartition {
 #define MOBI_INTRA_RETRY_PERCENT 90
 #define MOBI_RC_INITIAL_SLOPE 7.0f
 #define MOBI_RC_INITIAL_OFFSET 22.0f
+#define MOBI_PASS2_BUFFER_PRELOAD_CENTISECONDS 50
+#define MOBI_PASS2_MIN_ITERATIONS 3
+#define MOBI_PASS2_MAX_ITERATIONS 64
+#define MOBI_PASS2_MIN_IMPROVEMENT 0.05f
+#define MOBI_PASS2_CORRECTION 0.42f
 
 typedef struct MobiPartitionSeed {
     int x, y;
@@ -139,6 +147,15 @@ typedef struct MobiIntraMacroblock {
     int cost;
 } MobiIntraMacroblock;
 
+typedef struct MobiPassFrame {
+    float intercept;
+    int64_t fullness;
+    float correction;
+    int predicted_bits;
+    int qp;
+    uint8_t key;
+} MobiPassFrame;
+
 typedef struct MobiRateHistory {
     float *samples;
     float sum;
@@ -175,15 +192,20 @@ typedef struct MobiClipEncContext {
     int max_frame_size;
     int rc_enabled;
     int common_cbr;
+    int owns_stats_out;
     int previous_qp;
     int64_t frame_count;
     int64_t last_key_frame;
+    int64_t pass2_count;
+    MobiPassFrame *pass2;
     MobiRateHistory rc_history[2];
     float rc_target_bits_per_frame;
     float rc_next_qp;
     int64_t rc_debt;
     int rc_reactivity_frames;
     int rc_buffer_size;
+    long double rc_target_bits;
+    long double rc_actual_bits;
     long double total_sse;
     int qtab[16];
     int16_t qmul4[16];
@@ -405,6 +427,85 @@ static const int mobi_zero_threshold4[] = {
    39963, 50517,
 };
 
+static int parse_pass_stats(AVCodecContext *avctx, MobiPassFrame *frames,
+                            int64_t expected_count, int64_t *frame_count)
+{
+    const char *p = avctx->stats_in;
+    int64_t expected_index = 0;
+
+    if (!p || !*p) {
+        av_log(avctx, AV_LOG_ERROR,
+               "MobiClip pass 2 requires pass-1 statistics\n");
+        return AVERROR(EINVAL);
+    }
+
+    while (*p) {
+        int64_t index, bits;
+        int qp, intra_count, inter_count, consumed = 0;
+        char type;
+
+        while (*p == '\r' || *p == '\n')
+            p++;
+        if (!*p)
+            break;
+        if (sscanf(p, "%" SCNd64 " %1c %d %" SCNd64 " %d %d%n",
+                   &index, &type, &qp, &bits, &intra_count, &inter_count,
+                   &consumed) != 6 ||
+            consumed <= 0 || index != expected_index ||
+            (type != 'i' && type != 'p') ||
+            qp < MOBI_MIN_QP || qp > MOBI_MAX_QP ||
+            bits <= 0 || intra_count < 0 || inter_count < 0) {
+            av_log(avctx, AV_LOG_ERROR,
+                   "Invalid MobiClip pass-1 statistics at frame %" PRId64 "\n",
+                   expected_index);
+            return AVERROR_INVALIDDATA;
+        }
+        if (frames) {
+            if (expected_index >= expected_count)
+                return AVERROR_INVALIDDATA;
+            frames[expected_index].intercept =
+                logf((float)bits) + (float)qp / MOBI_RATE_QP_SCALE;
+            frames[expected_index].qp = qp;
+            frames[expected_index].key = type == 'i';
+        }
+        expected_index++;
+        p += consumed;
+        while (*p && *p != '\n')
+            p++;
+    }
+
+    if (!expected_index)
+        return AVERROR_INVALIDDATA;
+    if (frames && expected_index != expected_count)
+        return AVERROR_INVALIDDATA;
+
+    *frame_count = expected_index;
+    return 0;
+}
+
+static int mobiclip_target_bits(const AVCodecContext *avctx,
+                                double *fps, int64_t *target_bits)
+{
+    AVRational rate;
+
+    if (avctx->framerate.num > 0 && avctx->framerate.den > 0)
+        rate = avctx->framerate;
+    else if (avctx->time_base.num > 0 && avctx->time_base.den > 0)
+        rate = av_inv_q(avctx->time_base);
+    else
+        return AVERROR(EINVAL);
+
+    *fps = av_q2d(rate);
+    if (!isfinite(*fps) || !(*fps > 0.0))
+        return AVERROR(EINVAL);
+
+    *target_bits = av_rescale_rnd(avctx->bit_rate, rate.den, rate.num,
+                                  AV_ROUND_NEAR_INF);
+    if (*target_bits <= 0 || *target_bits == INT64_MAX)
+        return AVERROR(ERANGE);
+    return 0;
+}
+
 static int rate_history_init(MobiRateHistory *history, int size)
 {
     history->size = FFMAX(size, 1);
@@ -515,6 +616,212 @@ static void finish_common_cbr(MobiClipEncContext *s, int key, int64_t bits)
         -MOBI_RATE_QP_SCALE * logf(effective_target / mean);
 }
 
+static int predict_pass2_bits(const MobiPassFrame *frame, int qp)
+{
+    float bits = expf(frame->intercept -
+                      (float)qp / MOBI_RATE_QP_SCALE);
+
+    if (bits >= INT_MAX)
+        return INT_MAX;
+    return FFMAX((int)bits, 1);
+}
+
+static void repair_pass2_buffer(MobiPassFrame *frames, int current, int first,
+                                int underflow, int min_qp, int max_qp,
+                                int64_t capacity, int64_t *suffix_min,
+                                int64_t *suffix_max)
+{
+    const float down = expf(-1.0f / MOBI_RATE_QP_SCALE);
+    const float up   = expf( 1.0f / MOBI_RATE_QP_SCALE);
+
+    while ((underflow && frames[current].fullness < 0) ||
+           (!underflow && frames[current].fullness > capacity)) {
+        int candidate = -1;
+
+        suffix_min[current] = suffix_max[current] = frames[current].fullness;
+        for (int i = current - 1; i >= first; i--) {
+            suffix_min[i] = FFMIN(frames[i].fullness, suffix_min[i + 1]);
+            suffix_max[i] = FFMAX(frames[i].fullness, suffix_max[i + 1]);
+        }
+
+        /*
+         * Select underflow candidates by ascending QP and overflow candidates
+         * by descending QP, using frame index as the tie breaker.  A linear
+         * scan is sufficient for the short planning window.
+         */
+        for (int i = first; i <= current; i++) {
+            MobiPassFrame *frame = &frames[i];
+            int new_bits, delta;
+
+            if ((underflow && frame->qp >= max_qp) ||
+                (!underflow && frame->qp <= min_qp))
+                continue;
+            new_bits = FFMAX((int)(frame->predicted_bits *
+                                   (underflow ? down : up)), 1);
+            delta = frame->predicted_bits - new_bits;
+            if ((underflow && suffix_max[i] + delta > capacity) ||
+                (!underflow && suffix_min[i] + delta < 0))
+                continue;
+            if (candidate < 0 ||
+                (underflow && frame->qp < frames[candidate].qp) ||
+                (!underflow && frame->qp > frames[candidate].qp))
+                candidate = i;
+        }
+
+        if (candidate < 0) {
+            frames[current].fullness = underflow ? 0 : capacity;
+            return;
+        } else {
+            MobiPassFrame *frame = &frames[candidate];
+            int new_bits = FFMAX((int)(frame->predicted_bits *
+                                       (underflow ? down : up)), 1);
+            int delta = frame->predicted_bits - new_bits;
+
+            frame->qp += underflow ? 1 : -1;
+            frame->predicted_bits = new_bits;
+            for (int i = candidate; i <= current; i++)
+                frames[i].fullness =
+                    av_sat_add64(frames[i].fullness, delta);
+        }
+    }
+}
+
+static void simulate_pass2_plan(MobiPassFrame *frames, int64_t count,
+                                 int64_t target_bits, int64_t capacity,
+                                 int64_t initial_fullness,
+                                 int min_qp, int max_qp,
+                                 int64_t *suffix_min, int64_t *suffix_max)
+{
+    int64_t fullness = initial_fullness;
+
+    for (int64_t i = 0; i < count; i++) {
+        MobiPassFrame *frame = &frames[i];
+        /*
+         * Add each correction to the QP selected by the previous planning
+         * iteration.  The pass-record QP seeds only the first iteration.
+         */
+        frame->qp = av_clip((int)floor(frame->qp +
+                                       frame->correction + 0.5f),
+                            min_qp, max_qp);
+        frame->predicted_bits = predict_pass2_bits(frame, frame->qp);
+        fullness = av_sat_add64(fullness,
+                                target_bits - frame->predicted_bits);
+        frame->fullness = fullness;
+
+        if (frame->fullness < 0)
+            repair_pass2_buffer(frames, i, 0, 1, min_qp, max_qp,
+                                capacity, suffix_min, suffix_max);
+        else if (frame->fullness > capacity)
+            repair_pass2_buffer(frames, i, 0, 0, min_qp, max_qp,
+                                capacity, suffix_min, suffix_max);
+        fullness = frame->fullness;
+    }
+}
+
+static int setup_pass2(AVCodecContext *avctx)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+    int64_t *suffix_min = NULL, *suffix_max = NULL;
+    double fps;
+    float previous_variance = FLT_MAX;
+    int64_t target_bits, capacity, initial_fullness;
+    int64_t count, predicted_total = 0;
+    int min_qp = INT_MAX, max_qp = 0;
+    int iteration = 0;
+    int ret;
+
+    if (avctx->qmin < MOBI_MIN_QP || avctx->qmax > MOBI_MAX_QP ||
+        avctx->qmin > avctx->qmax) {
+        av_log(avctx, AV_LOG_ERROR,
+               "Rate-control QP range must be within 12..63\n");
+        return AVERROR(EINVAL);
+    }
+    if ((ret = mobiclip_target_bits(avctx, &fps, &target_bits)) < 0) {
+        av_log(avctx, AV_LOG_ERROR,
+               "Invalid frame rate or bitrate for MobiClip pass 2\n");
+        return ret;
+    }
+    if ((ret = parse_pass_stats(avctx, NULL, 0, &count)) < 0)
+        return ret;
+    if (count > INT_MAX)
+        return AVERROR(ERANGE);
+    if ((uint64_t)count > SIZE_MAX / sizeof(*s->pass2))
+        return AVERROR(ENOMEM);
+
+    s->pass2 = av_calloc((size_t)count, sizeof(*s->pass2));
+    suffix_min = av_malloc_array((size_t)count, sizeof(*suffix_min));
+    suffix_max = av_malloc_array((size_t)count, sizeof(*suffix_max));
+    if (!s->pass2 || !suffix_min || !suffix_max) {
+        ret = AVERROR(ENOMEM);
+        goto fail;
+    }
+    s->pass2_count = count;
+    if ((ret = parse_pass_stats(avctx, s->pass2, count, &count)) < 0)
+        goto fail;
+    if (avctx->rc_buffer_size > 0) {
+        capacity = avctx->rc_buffer_size;
+    } else {
+        capacity = av_rescale_rnd(
+            avctx->bit_rate,
+            2 * MOBI_PASS2_BUFFER_PRELOAD_CENTISECONDS, 100,
+            AV_ROUND_NEAR_INF);
+    }
+    capacity = FFMAX(capacity, 1);
+    initial_fullness = avctx->rc_initial_buffer_occupancy > 0 ?
+                       avctx->rc_initial_buffer_occupancy : capacity / 2;
+    initial_fullness = av_clip64(initial_fullness, 0, capacity);
+
+    for (; iteration < MOBI_PASS2_MAX_ITERATIONS; iteration++) {
+        float sum = 0;
+        float variance = 0;
+        float mean;
+
+        simulate_pass2_plan(s->pass2, count, target_bits, capacity,
+                            initial_fullness,
+                            avctx->qmin, avctx->qmax,
+                            suffix_min, suffix_max);
+        for (int64_t i = 0; i < count; i++)
+            sum += s->pass2[i].qp;
+        mean = sum / (float)count;
+        for (int64_t i = 0; i < count; i++) {
+            float distance = s->pass2[i].qp - mean;
+
+            variance += distance * distance;
+        }
+
+        if (iteration + 1 >= MOBI_PASS2_MIN_ITERATIONS &&
+            (variance <= 0 ||
+             (1.0f - MOBI_PASS2_MIN_IMPROVEMENT) *
+             previous_variance <= variance))
+            break;
+        previous_variance = variance;
+        for (int64_t i = 0; i < count; i++)
+            s->pass2[i].correction =
+                (mean - s->pass2[i].qp) * MOBI_PASS2_CORRECTION;
+    }
+
+    for (int64_t i = 0; i < count; i++) {
+        min_qp = FFMIN(min_qp, s->pass2[i].qp);
+        max_qp = FFMAX(max_qp, s->pass2[i].qp);
+        predicted_total += s->pass2[i].predicted_bits;
+    }
+    s->rc_target_bits = (long double)target_bits * count;
+    av_log(avctx, AV_LOG_INFO,
+           "MobiClip two-pass rate control selected qp=%d..%d for %" PRId64
+           " frames at %" PRId64 " bit/s (predicted %" PRId64 " bits)\n",
+           min_qp, max_qp, count, avctx->bit_rate, predicted_total);
+    av_free(suffix_min);
+    av_free(suffix_max);
+    return 0;
+
+fail:
+    av_free(suffix_min);
+    av_free(suffix_max);
+    av_freep(&s->pass2);
+    s->pass2_count = 0;
+    return ret;
+}
+
 static int find_mask(const uint8_t *table, int count, unsigned mask)
 {
     for (int i = 0; i < count; i++)
@@ -3771,9 +4078,8 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
     s->moflex = avctx->profile == AV_PROFILE_MOBICLIP_MOFLEX;
     if ((ret = av_image_check_size(avctx->width, avctx->height, 0, avctx)) < 0)
         return ret;
-    if (pass_flags) {
-        av_log(avctx, AV_LOG_ERROR,
-               "MobiClip two-pass rate control is not supported\n");
+    if (pass_flags == (AV_CODEC_FLAG_PASS1 | AV_CODEC_FLAG_PASS2)) {
+        av_log(avctx, AV_LOG_ERROR, "Pass 1 and pass 2 are mutually exclusive\n");
         return AVERROR(EINVAL);
     }
     if (avctx->gop_size < 0)
@@ -3832,7 +4138,31 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
     if ((ret = av_frame_get_buffer(s->recon, 32)) < 0)
         return ret;
 
-    if (s->qp < 0 && avctx->bit_rate > 0) {
+    if (pass_flags && avctx->bit_rate > 0) {
+        s->rc_enabled = 1;
+        if (pass_flags & AV_CODEC_FLAG_PASS2) {
+            if ((ret = setup_pass2(avctx)) < 0)
+                return ret;
+            if (!s->pass2[0].key) {
+                av_log(avctx, AV_LOG_ERROR,
+                       "MobiClip pass 1 must begin with an I frame\n");
+                return AVERROR(EINVAL);
+            }
+        } else {
+            s->common_cbr = 1;
+            if ((ret = setup_common_cbr(avctx)) < 0)
+                return ret;
+            if (pass_flags & AV_CODEC_FLAG_PASS1) {
+                avctx->stats_out = av_malloc(MOBI_PASS_STATS_SIZE);
+                if (!avctx->stats_out)
+                    return AVERROR(ENOMEM);
+                s->owns_stats_out = 1;
+            }
+        }
+    } else if (pass_flags) {
+        av_log(avctx, AV_LOG_WARNING,
+               "Ignoring pass flag without a positive bit_rate\n");
+    } else if (s->qp < 0 && avctx->bit_rate > 0) {
         s->rc_enabled = s->common_cbr = 1;
         if ((ret = setup_common_cbr(avctx)) < 0)
             return ret;
@@ -3867,6 +4197,16 @@ static av_cold int mobiclip_encode_close(AVCodecContext *avctx)
                s->total_sse, psnr);
     }
 
+    if (s->pass2_count && s->frame_count) {
+        av_log(avctx, AV_LOG_INFO,
+               "MobiClip pass 2 wrote %.0f video bits for a %.0f-bit target\n",
+               (double)s->rc_actual_bits, (double)s->rc_target_bits);
+        if (s->frame_count < s->pass2_count)
+            av_log(avctx, AV_LOG_WARNING,
+                   "Pass 2 used only %" PRId64 " of %" PRId64
+                   " pass-1 frame records\n",
+                   s->frame_count, s->pass2_count);
+    }
     for (int i = 0; i < FF_ARRAY_ELEMS(s->reference); i++)
         av_frame_free(&s->reference[i]);
     for (int i = 0; i < FF_ARRAY_ELEMS(s->me_reference); i++)
@@ -3880,8 +4220,11 @@ static av_cold int mobiclip_encode_close(AVCodecContext *avctx)
     av_frame_free(&s->recon);
     av_frame_free(&s->input);
     av_freep(&s->motion);
+    av_freep(&s->pass2);
     for (int type = 0; type < 2; type++)
         av_freep(&s->rc_history[type].samples);
+    if (s->owns_stats_out)
+        av_freep(&avctx->stats_out);
     return 0;
 }
 
@@ -3930,6 +4273,25 @@ static int mobiclip_encode_frame(AVCodecContext *avctx, AVPacket *pkt,
 
     if (!frame)
         return 0;
+    if (s->pass2) {
+        const MobiPassFrame *pass;
+        int64_t pass_index = s->frame_count;
+
+        if (s->frame_count >= s->pass2_count) {
+            av_log(avctx, AV_LOG_ERROR,
+                   "Pass 2 received more frames than pass 1\n");
+            return AVERROR_INVALIDDATA;
+        }
+        pass = &s->pass2[pass_index];
+        if (mandatory_key && !pass->key) {
+            av_log(avctx, AV_LOG_ERROR,
+                   "Pass-1 frame type violates a required keyframe at frame %" PRId64 "\n",
+                   pass_index);
+            return AVERROR_INVALIDDATA;
+        }
+        key = pass->key;
+        mobi_set_qp(s, pass->qp);
+    }
     if (s->common_cbr)
         mobi_set_qp(s, common_cbr_qp(avctx, s, key));
     if (max_size > INT_MAX)
@@ -3986,8 +4348,15 @@ static int mobiclip_encode_frame(AVCodecContext *avctx, AVPacket *pkt,
     pkt->size = size;
     frame_bits = 8LL * size;
 
+    if (s->rc_enabled && (avctx->flags & AV_CODEC_FLAG_PASS1))
+        snprintf(avctx->stats_out, MOBI_PASS_STATS_SIZE,
+                 "%" PRId64 " %c %d %" PRId64 " %d %d\n",
+                 s->frame_count, key ? 'i' : 'p', s->qp, frame_bits,
+                 intra_count, inter_count);
     if (s->common_cbr)
         finish_common_cbr(s, key, frame_bits);
+    if (s->pass2)
+        s->rc_actual_bits += frame_bits;
 
     if (avctx->flags & AV_CODEC_FLAG_PSNR) {
         uint64_t error[3];
diff --git a/tests/fate/mobiclip.mak b/tests/fate/mobiclip.mak
index 02d2e773d8..c8c38b6b9a 100644
--- a/tests/fate/mobiclip.mak
+++ b/tests/fate/mobiclip.mak
@@ -18,9 +18,7 @@ FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)) += \
     fate-mobiclip-moc5-nested-partitions \
     fate-mobiclip-moc5-deep-partitions \
     fate-mobiclip-moc5-mixed-partitions \
-    fate-mobiclip-moc5-certificate-only \
-    fate-mobiclip-moc5-common-cbr \
-    fate-mobiclip-moc5-onepass-cbr
+    fate-mobiclip-moc5-certificate-only
 fate-mobiclip-moc5-roundtrip: CMD = mobiclip_moc5_roundtrip 128 80 25 12 4 20
 fate-mobiclip-moc5-roundtrip: CMP = null
 fate-mobiclip-moc5-intra-modes: CMD = mobiclip_moc5_intra_modes
@@ -43,10 +41,6 @@ fate-mobiclip-moc5-mixed-partitions: CMD = mobiclip_moc5_mixed_partitions
 fate-mobiclip-moc5-mixed-partitions: CMP = null
 fate-mobiclip-moc5-certificate-only: CMD = mobiclip_moc5_certificate_only
 fate-mobiclip-moc5-certificate-only: CMP = null
-fate-mobiclip-moc5-common-cbr: CMD = mobiclip_moc5_common_cbr
-fate-mobiclip-moc5-common-cbr: CMP = null
-fate-mobiclip-moc5-onepass-cbr: CMD = mobiclip_moc5_onepass_cbr
-fate-mobiclip-moc5-onepass-cbr: CMP = null
 
 FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)) += \
     fate-mobiclip-moc5-reference-drift
@@ -71,6 +65,27 @@ fate-mobiclip-moc5-scene-retry: CMP = null
 fate-mobiclip-moc5-adaptive-keyframes: CMD = mobiclip_moc5_adaptive_keyframes
 fate-mobiclip-moc5-adaptive-keyframes: CMP = null
 
+MOBICLIP_MOC5_TWOPASS_DEPS = $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)
+
+FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_TWOPASS_DEPS)) += \
+    fate-mobiclip-moc5-common-cbr \
+    fate-mobiclip-moc5-iterative-pass-plan \
+    fate-mobiclip-moc5-onepass-cbr \
+    fate-mobiclip-moc5-twopass-high-qp
+fate-mobiclip-moc5-common-cbr: CMD = mobiclip_moc5_common_cbr
+fate-mobiclip-moc5-common-cbr: CMP = null
+fate-mobiclip-moc5-iterative-pass-plan: CMD = mobiclip_moc5_iterative_pass_plan
+fate-mobiclip-moc5-iterative-pass-plan: CMP = null
+fate-mobiclip-moc5-onepass-cbr: CMD = mobiclip_moc5_onepass_cbr
+fate-mobiclip-moc5-onepass-cbr: CMP = null
+fate-mobiclip-moc5-twopass-high-qp: CMD = mobiclip_moc5_twopass_high_qp
+fate-mobiclip-moc5-twopass-high-qp: CMP = null
+
+FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_TWOPASS_DEPS) COLOR_FILTER) += \
+    fate-mobiclip-moc5-twopass
+fate-mobiclip-moc5-twopass: CMD = mobiclip_moc5_twopass
+fate-mobiclip-moc5-twopass: CMP = null
+
 MOBICLIP_MOC5_ADPCM_DEPS = $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)          \
                            SINE_FILTER ARESAMPLE_FILTER             \
                            PCM_S16LE_MUXER PCM_S16LE_DEMUXER        \
diff --git a/tests/mobiclip-fate.sh b/tests/mobiclip-fate.sh
index cce4e345dd..82ae841673 100644
--- a/tests/mobiclip-fate.sh
+++ b/tests/mobiclip-fate.sh
@@ -109,7 +109,6 @@ mobiclip_moc5_roundtrip(){
     }
 }
 
-
 # Verify that a repeated flat frame collapses to true symbol-0 skip
 # macroblocks in both profiles.
 mobiclip_moc5_direct_motion(){
@@ -136,7 +135,6 @@ mobiclip_moc5_direct_motion(){
     done
 }
 
-
 # A textured direct-QP P picture chooses predicted intra for its first root
 # macroblock. Check the exact VlcTable 0/1 root prefixes and decode the
 # table-0 residual payload used by P-intra mode 7.
@@ -202,7 +200,6 @@ mobiclip_moc5_p_intra(){
     done
 }
 
-
 # Intra RDO can choose a four-4x4 prediction route even when all
 # four residual blocks quantize to zero. The i4 selector still carries the
 # split and its four prediction modes. The exact plane-offset search keeps
@@ -248,23 +245,25 @@ mobiclip_moc5_zero_mask_intra_split(){
     }
 }
 
-# Exercise a one-pass CBR scene retry. The dissimilar second frame must be
-# accepted as an I frame rather than leaving the discarded P attempt visible.
+# Common cbr retries that same all-intra P attempt as I.  Only the accepted
+# attempt may reach the pass log, and its size/count fields are the final
+# word-padded packet's bytes*8 and root-macroblock equivalents.
 mobiclip_moc5_scene_retry(){
     width=128
     height=16
     rate=25
-    frames=2
     bitrate=50000
     black="${outdir}/${test}.black.yuv"
     white="${outdir}/${test}.white.yuv"
     source="${outdir}/${test}.source.yuv"
     encoded="${outdir}/${test}.mo"
-    cleanfiles="$cleanfiles $black $white $source $encoded"
+    passlog="${outdir}/${test}.stats"
+    cleanfiles="$cleanfiles $black $white $source $encoded ${passlog}-0.log"
     tblack=$(target_path "$black")
     twhite=$(target_path "$white")
     tsource=$(target_path "$source")
     tencoded=$(target_path "$encoded")
+    tpasslog=$(target_path "$passlog")
 
     ffmpeg -auto_conversion_filters -f lavfi \
         -i "color=c=black:size=${width}x${height}:rate=${rate}" \
@@ -278,12 +277,30 @@ mobiclip_moc5_scene_retry(){
 
     ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
         -video_size "${width}x${height}" -framerate "$rate" \
-        -i "$tsource" -frames:v "$frames" -an -c:v mobiclip \
-        -profile:v moflex -b:v "$bitrate" -g 10 \
+        -i "$tsource" -frames:v 2 -an -c:v mobiclip -profile:v moflex \
+        -b:v "$bitrate" -g 10 -pass 1 -passlogfile "$tpasslog" \
         -f mobiclip_mo -y "$tencoded" || return
 
-    sh "${base}/moc5check.sh" "$encoded" "$width" "$height" \
-        $((rate * 256)) "$frames" 10 || return
+    [ "$(wc -l < "${passlog}-0.log")" -eq 2 ] || {
+        echo "scene retry emitted a discarded attempt to the pass log" >&2
+        return 1
+    }
+    set -- $(sed -n '2p' "${passlog}-0.log")
+    [ "$#" -eq 6 ] && [ "$1" -eq 1 ] && [ "$2" = i ] &&
+        [ "$5" -eq $((width * height / 256)) ] && [ "$6" -eq 0 ] || {
+        echo "scene retry pass record is '$(sed -n '2p' "${passlog}-0.log")'" >&2
+        return 1
+    }
+    stats_bits=$4
+    second_size=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=size -of csv=p=0 \
+        "$tencoded" | sed -n '2p') || return
+    # MOC5 stores each even-byte encoder payload in a four-byte-aligned slot.
+    [ $((stats_bits % 8)) -eq 0 ] &&
+        [ "$second_size" -eq $((((stats_bits / 8) + 3) / 4 * 4)) ] || {
+        echo "scene retry logged $stats_bits bits for a $second_size-byte aligned MOC5 packet" >&2
+        return 1
+    }
     second_flags=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
         -select_streams v:0 -show_entries packet=flags -of csv=p=0 \
         "$tencoded" | sed -n '2p') || return
@@ -296,7 +313,6 @@ mobiclip_moc5_scene_retry(){
     esac
 }
 
-
 # Exercise the I-picture mode-6/mode-7 selector on a small textured image.
 # The exact packet size also locks hierarchical 16x16 -> 8x8 ->
 # 4x4 availability propagation, while a decoder round trip catches ordering
@@ -343,7 +359,6 @@ mobiclip_moc5_intra_modes(){
     done
 }
 
-
 # Choose the predicted 8x8 mode before adding the transform-selector
 # lambda, and measures transform distortion in the residual domain before
 # prediction addition can clip to uint8. Lock the complete deterministic
@@ -386,7 +401,6 @@ mobiclip_moc5_deferred_intra8_selector(){
     }
 }
 
-
 # The RDO lambda follows 0.035 * exp(qp * ln(2) / 3). Lock the complete
 # QP-48 packet because small changes to the curve alter mode decisions without
 # necessarily changing the aligned packet size.
@@ -428,7 +442,6 @@ mobiclip_moc5_qp_lambda(){
     }
 }
 
-
 # The fixed-DC intra probe keeps its prediction samples in the working
 # plane: residual evaluation writes only coefficient/reconstruction scratch.
 # The selector also leaves that probe state in place when it enters the full
@@ -480,7 +493,6 @@ mobiclip_moc5_intra_probe_state(){
     }
 }
 
-
 # Alternate two flat frames so frame three has an exact match only at
 # reference age two.  The first root symbol is checked in both frame profiles,
 # then the complete stream is decoded to exercise the rotating six-slot pool.
@@ -580,7 +592,6 @@ mobiclip_moc5_multiref(){
     done
 }
 
-
 # Keep more than four complete rotations of the six-picture reference ring.
 # enc_recon_frame_test Adler-hashes every plane of every exported encoder
 # reconstruction and independently decoded frame, and fails at the first
@@ -607,7 +618,6 @@ mobiclip_moc5_reference_drift(){
     done
 }
 
-
 # Give the two halves of the first macroblock different exact motions.  This
 # forces the recursive partition path and locks both root split VLCs
 # against the decoder's transposed partition-class order.
@@ -696,7 +706,6 @@ mobiclip_moc5_partitions(){
     done
 }
 
-
 # Make the first macroblock's four 8x8 quadrants use different exact motions.
 # Both profiles must split the root vertically and then split its first 8x16
 # child horizontally, exercising a real recursive tree rather than only the
@@ -778,7 +787,6 @@ mobiclip_moc5_nested_partitions(){
     done
 }
 
-
 # Give every 4x4 cell in the first macroblock a different exact integer
 # motion.  The selected tree must therefore pass through real 16x4 and 8x4
 # leaves before terminating at 4x4; an exact packet prefix locks the
@@ -862,7 +870,6 @@ mobiclip_moc5_deep_partitions(){
     done
 }
 
-
 # Exercise two partition details that a root-only recursion misses:
 # sibling leaves may refine in different orientations, and an explicitly
 # enabled mixed-reference tree may select a different reference per leaf.
@@ -1005,7 +1012,6 @@ mobiclip_moc5_mixed_partitions(){
     done
 }
 
-
 # A Wii player requires an authenticated pc certificate, but JD3/JD4 do not
 # require the optional per-file cc signature.  The muxer treats certificate
 # bytes as opaque; this deterministic dummy verifies the container topology.
@@ -1030,7 +1036,6 @@ mobiclip_moc5_certificate_only(){
         $((rate * 256)) "$frames" 4 0 0 1 || return
 }
 
-
 # Verify that an explicitly forced keyframe restarts the maximum GOP interval.
 mobiclip_moc5_adaptive_keyframes(){
     width=128
@@ -1079,8 +1084,10 @@ mobiclip_moc5_adaptive_keyframes(){
     }
 }
 
-# Exercise the one-pass common-CBR controller across ordinary and forced-intra
-# frames. The stream must change quantizer and preserve the forced keyframe.
+# Exercise the common-CBR controller independently of pass 2.  The
+# initial QP includes both initialization and first-frame boosts. Frame two is
+# forced intra to verify that I and P
+# samples feed one shared next-QP rather than independent type controllers.
 mobiclip_moc5_common_cbr(){
     width=128
     height=80
@@ -1089,30 +1096,36 @@ mobiclip_moc5_common_cbr(){
     bitrate=100000
 
     encoded="${outdir}/${test}.mo"
-    cleanfiles="$cleanfiles $encoded"
+    passlog="${outdir}/${test}.stats"
+    cleanfiles="$cleanfiles $encoded ${passlog}-0.log"
     tencoded=$(target_path "$encoded")
+    tpasslog=$(target_path "$passlog")
 
     ffmpeg -auto_conversion_filters -f lavfi \
         -i "testsrc2=size=${width}x${height}:rate=${rate}" \
         -frames:v "$frames" -an -c:v mobiclip -b:v "$bitrate" -g 24 \
         -force_key_frames "expr:eq(n,2)" \
+        -pass 1 -passlogfile "$tpasslog" \
         -f mobiclip_mo -y "$tencoded" || return
 
-    sh "${base}/moc5check.sh" "$encoded" "$width" "$height" \
-        $((rate * 256)) "$frames" 24 0 1 || return
-    third_flags=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
-        -select_streams v:0 -show_entries packet=flags -of csv=p=0 \
-        "$tencoded" | sed -n '3p') || return
-    case "$third_flags" in
-        *K*) ;;
-        *)
-            echo "forced common-CBR packet is not marked key" >&2
-            return 1
-            ;;
-    esac
+    [ "$(wc -l < "${passlog}-0.log")" -eq "$frames" ] || {
+        echo "common CBR did not emit one statistics record per frame" >&2
+        return 1
+    }
+    first=$(awk 'NR == 1 { print $2, $3 }' "${passlog}-0.log")
+    [ "$first" = "i 23" ] || {
+        echo "common CBR initial type/QP is '$first', expected 'i 23'" >&2
+        return 1
+    }
+    third=$(awk 'NR == 3 { print $2, $3 }' "${passlog}-0.log")
+    third_type=${third%% *}
+    third_qp=${third#* }
+    [ "$third_type" = "i" ] && [ "$third_qp" -lt 30 ] || {
+        echo "common CBR forced-I type/QP is '$third', expected shared-model QP below 30" >&2
+        return 1
+    }
 }
 
-
 # Exercise the same common-CBR path without enabling a pass flag.
 # This is the ordinary -b:v interface: no explicit QP and no statistics file.
 mobiclip_moc5_onepass_cbr(){
@@ -1145,6 +1158,186 @@ mobiclip_moc5_onepass_cbr(){
     }
 }
 
+# Feed a fixed pass-one record set through the two-pass planner. The expected
+# QPs lock the
+# iterative rule: each analysis correction is applied to the preceding
+# planned QP rather than repeatedly to the pass-one source QP.
+mobiclip_moc5_iterative_pass_plan(){
+    width=128
+    height=80
+    rate=25
+    frames=12
+    bitrate=250000
+    expected_qps=30,30,31,32,32,32,30,30,30,31,32,32
+
+    encoded="${outdir}/${test}.mo"
+    passlog="${outdir}/${test}.stats"
+    cleanfiles="$cleanfiles $encoded ${passlog}-0.log"
+    tencoded=$(target_path "$encoded")
+    tpasslog=$(target_path "$passlog")
+
+    cat > "${passlog}-0.log" <<'EOF'
+0 i 28 8000 80 0
+1 p 30 2000 5 75
+2 p 31 2300 8 72
+3 p 32 2700 12 68
+4 p 33 3100 20 60
+5 p 34 3600 30 50
+6 i 27 9500 80 0
+7 p 29 1800 3 77
+8 p 30 2200 7 73
+9 p 31 2900 15 65
+10 p 32 4000 28 52
+11 p 33 5200 42 38
+EOF
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -an -c:v mobiclip -b:v "$bitrate" -g 6 \
+        -pass 2 -passlogfile "$tpasslog" \
+        -f mobiclip_mo -y "$tencoded" || return
+
+    sh "${base}/moc5check.sh" "$encoded" "$width" "$height" \
+        $((rate * 256)) "$frames" 6 0 0 0 "$expected_qps" || return
+}
+
+# Use deliberately dissimilar easy and complex halves to prove that pass 2
+# writes changing frame quantizers while respecting the default
+# three-second half-fullness margin.
+mobiclip_moc5_twopass(){
+    width=128
+    height=80
+    rate=25
+    half_frames=24
+    frames=$((half_frames * 2))
+    gop=24
+    bitrate=250000
+    max_frame_size=3000
+
+    black="${outdir}/${test}.black.yuv"
+    complex="${outdir}/${test}.complex.yuv"
+    source="${outdir}/${test}.source.yuv"
+    pass1="${outdir}/${test}.pass1.mo"
+    pass2="${outdir}/${test}.pass2.mo"
+    decoded="${outdir}/${test}.decoded.yuv"
+    passlog="${outdir}/${test}.stats"
+    cleanfiles="$cleanfiles $black $complex $source $pass1 $pass2 $decoded"
+    cleanfiles="$cleanfiles ${passlog}-0.log"
+    tblack=$(target_path "$black")
+    tcomplex=$(target_path "$complex")
+    tsource=$(target_path "$source")
+    tpass1=$(target_path "$pass1")
+    tpass2=$(target_path "$pass2")
+    tdecoded=$(target_path "$decoded")
+    tpasslog=$(target_path "$passlog")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "color=c=black:size=${width}x${height}:rate=${rate}" \
+        -frames:v "$half_frames" -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tblack" || return
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$half_frames" -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tcomplex" || return
+    cat "$black" "$complex" > "$source" || return
+
+    ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+        -video_size "${width}x${height}" -framerate "$rate" -i "$tsource" \
+        -frames:v "$frames" -an -c:v mobiclip -b:v "$bitrate" -g "$gop" \
+        -pass 1 -passlogfile "$tpasslog" -f mobiclip_mo -y "$tpass1" || return
+    ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+        -video_size "${width}x${height}" -framerate "$rate" -i "$tsource" \
+        -frames:v "$frames" -an -c:v mobiclip -b:v "$bitrate" -g "$gop" \
+        -pass 2 -passlogfile "$tpasslog" \
+        -max_frame_size "$max_frame_size" \
+        -f mobiclip_mo -y "$tpass2" || return
+
+    [ "$(grep -Ec '^[0-9]+ [ip] (1[2-9]|[2-3][0-9]|4[0-8]) [1-9][0-9]* [0-9]+ [0-9]+$' \
+        "${passlog}-0.log")" -eq "$frames" ] || {
+        echo "pass 1 did not emit one statistics record per frame" >&2
+        return 1
+    }
+    sh "${base}/moc5check.sh" "$pass2" "$width" "$height" \
+        $((rate * 256)) "$frames" "$gop" 0 1 || return
+
+    video_bits=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=size -of csv=p=0 "$tpass2" | \
+        awk '{ total += $1 * 8 } END { print total + 0 }') || return
+    target_bits=$((bitrate * frames / rate))
+    difference=$((video_bits - target_bits))
+    [ "$difference" -ge 0 ] || difference=$((-difference))
+    [ "$difference" -le $((bitrate * 3)) ] || {
+        echo "pass-2 size $video_bits bits exceeds the buffer margin around $target_bits" >&2
+        return 1
+    }
+
+    largest_frame=$(run ffprobe${PROGSUF}${EXECSUF} -v error \
+        -select_streams v:0 -show_entries packet=size -of csv=p=0 "$tpass2" | \
+        sort -n | tail -1) || return
+    [ "$largest_frame" -le "$max_frame_size" ] || {
+        echo "pass-2 frame $largest_frame exceeds max_frame_size $max_frame_size" >&2
+        return 1
+    }
+
+    ffmpeg -auto_conversion_filters -i "$tpass2" -map 0:v:0 \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+    expected_size=$((width * height * 3 / 2 * frames))
+    [ "$(wc -c < "$decoded")" -eq "$expected_size" ] || {
+        echo "adaptive two-pass output did not decode every frame" >&2
+        return 1
+    }
+}
+
+# Ensure pass-two statistics accept the complete six-bit quantizer range,
+# including values above the default rate-control maximum.
+mobiclip_moc5_twopass_high_qp(){
+    width=64
+    height=64
+    rate=25
+    frames=4
+    qp=55
+    bitrate=10000
+
+    pass1="${outdir}/${test}.pass1.mo"
+    pass2="${outdir}/${test}.pass2.mo"
+    decoded="${outdir}/${test}.decoded.yuv"
+    passlog="${outdir}/${test}.stats"
+    cleanfiles="$cleanfiles $pass1 $pass2 $decoded ${passlog}-0.log"
+    tpass1=$(target_path "$pass1")
+    tpass2=$(target_path "$pass2")
+    tdecoded=$(target_path "$decoded")
+    tpasslog=$(target_path "$passlog")
+
+    for pass in 1 2; do
+        if [ "$pass" -eq 1 ]; then
+            output=$tpass1
+        else
+            output=$tpass2
+        fi
+        ffmpeg -auto_conversion_filters -f lavfi \
+            -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+            -frames:v "$frames" -pix_fmt yuv420p -an -c:v mobiclip \
+            -profile:v moflex -b:v "$bitrate" -qmin "$qp" -qmax "$qp" \
+            -pass "$pass" -passlogfile "$tpasslog" \
+            -f mobiclip_mo -y "$output" || return
+    done
+
+    [ "$(awk -v qp="$qp" '$3 == qp { count++ } END { print count + 0 }' \
+        "${passlog}-0.log")" -eq "$frames" ] || {
+        echo "pass 1 did not preserve high quantizer $qp in every record" >&2
+        return 1
+    }
+
+    ffmpeg -auto_conversion_filters -i "$tpass2" -map 0:v:0 \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+    [ "$(wc -c < "$decoded")" -eq \
+        $((width * height * 3 / 2 * frames)) ] || {
+        echo "high-QP two-pass output did not decode every frame" >&2
+        return 1
+    }
+}
 
 # Exercise MOC5's Wii stereo ADPCM path.  The sample count is deliberately an
 # exact number of 256-sample blocks, so any length mismatch is container loss,
diff --git a/tests/moc5check.sh b/tests/moc5check.sh
index e8d9412dcd..04d73f147e 100644
--- a/tests/moc5check.sh
+++ b/tests/moc5check.sh
@@ -24,7 +24,9 @@
 #
 # Usage: moc5check.sh file width height fps_fixed frames [gop] [compact_ki]
 #                     [minimum_qp_changes] [certificate_mode]
+#                     [expected_qp_sequence]
 # certificate_mode: 0 = no certificate, 1 = pc certificate only
+# expected_qp_sequence: comma-separated direct decoder QPs
 
 set -eu
 
@@ -37,6 +39,7 @@ expected_gop=${6:-0}
 expected_compact_ki=${7:-0}
 expected_qp_changes=${8:-0}
 expected_certificate_mode=${9:-0}
+expected_qp_sequence=${10:-}
 
 fail()
 {
@@ -230,6 +233,7 @@ video_bytes=0
 audio_bytes=0
 current_qp=-1
 qp_changes=0
+qp_sequence=
 while [ "$frame" -lt "$expected_frames" ]; do
     [ $((offset + 8)) -le "$file_size" ] || fail "truncated chunk header for frame $frame"
     chunk_size=$(read_u32le "$file" "$offset")
@@ -241,7 +245,7 @@ while [ "$frame" -lt "$expected_frames" ]; do
     [ "$video_size" -le $((chunk_size - 4)) ] ||
         fail "frame $frame video size exceeds its chunk"
 
-    if [ "$expected_qp_changes" -gt 0 ]; then
+    if [ "$expected_qp_changes" -gt 0 ] || [ -n "$expected_qp_sequence" ]; then
         [ "$video_size" -ge 4 ] || fail "frame $frame video header is too short"
         set -- $(read_bytes "$file" $((offset + 8)) 4)
         [ "$#" -eq 4 ] || fail "short video header at frame $frame"
@@ -281,6 +285,13 @@ while [ "$frame" -lt "$expected_frames" ]; do
             qp_changes=$((qp_changes + 1))
         fi
         current_qp=$next_qp
+        if [ -n "$expected_qp_sequence" ]; then
+            if [ -n "$qp_sequence" ]; then
+                qp_sequence="${qp_sequence},${next_qp}"
+            else
+                qp_sequence=$next_qp
+            fi
+        fi
     fi
 
     raw_end=$((offset + chunk_size))
@@ -311,6 +322,8 @@ done
     fail "terminal chunk is not zero"
 [ "$qp_changes" -ge "$expected_qp_changes" ] ||
     fail "only $qp_changes QP changes, expected at least $expected_qp_changes"
+[ -z "$expected_qp_sequence" ] || [ "$qp_sequence" = "$expected_qp_sequence" ] ||
+    fail "QP sequence '$qp_sequence', expected '$expected_qp_sequence'"
 
 echo "moc5: frames=$frame keys=$key_count video_bytes=$video_bytes" \
      "audio_bytes=$audio_bytes qp_changes=$qp_changes size=$file_size"
-- 
2.52.0


>From ab2a7e6c5c89968e32a1192d5b67f7368644f343 Mon Sep 17 00:00:00 2001
From: Martijn Brouwer <[email protected]>
Date: Fri, 31 Jul 2026 00:41:10 +0200
Subject: [PATCH 10/10] avcodec/mobiclipenc: add temporal range parallelism

Add optional high-latency parallel encoding over independently decodable keyframe ranges.

Buffer input long enough to choose deterministic range boundaries, schedule ranges through FFmpeg's internal executor, and merge packets and pass-one statistics back into global order.

Add segmented pass-two offsets for worker contexts, reject incompatible reconstruction and pass configurations, and keep the feature disabled by default because of its latency and memory requirements.

Signed-off-by: Martijn Brouwer <[email protected]>
---
 doc/encoders.texi                 |  33 +-
 libavcodec/Makefile               |   5 +-
 libavcodec/mobiclipenc.c          |  82 +++-
 libavcodec/mobiclipenc_parallel.c | 695 ++++++++++++++++++++++++++++++
 libavcodec/mobiclipenc_parallel.h |  42 ++
 tests/fate/mobiclip.mak           |  16 +-
 tests/mobiclip-fate.sh            | 155 +++++++
 7 files changed, 1010 insertions(+), 18 deletions(-)
 create mode 100644 libavcodec/mobiclipenc_parallel.c
 create mode 100644 libavcodec/mobiclipenc_parallel.h

diff --git a/doc/encoders.texi b/doc/encoders.texi
index 4d7f549d5f..9c49ea2cc2 100644
--- a/doc/encoders.texi
+++ b/doc/encoders.texi
@@ -3599,9 +3599,34 @@ bitrates to avoid frequently discarding detail accumulated by P-frames.
 
 @item threads @var{integer}
 Set the encoder thread budget. A value of 0, the default, uses the number of
-logical processors reported by the host. Additional threads are used only to
-construct the current frame's references, so packet output remains bounded to
-one input frame.
+logical processors reported by the host. Normally, additional threads are used
+only to construct the current frame's references, so packet output remains
+bounded to one input frame.
+
+@item temporal_ranges @var{boolean}
+Enable temporal parallelism over independently decodable keyframe ranges.
+This is disabled by default and requires at least two encoding threads. The
+scheduler buffers approximately 30 seconds of uncompressed input before it can
+select a range boundary, which adds corresponding latency and can require
+several gigabytes for high-resolution video. It returns one globally ordered
+packet stream and one globally numbered pass-one statistics stream, so callers
+do not need to split, merge, or remux either pass themselves.
+
+Temporal ranges are incompatible with reconstructed-frame output. Pass flags
+used with temporal ranges require a positive bitrate. The thread count changes
+only the scheduling of a temporal-range encode, not its range boundaries or
+encoded output.
+
+@item pass2_offset @var{integer}
+Select the first global pass-one frame record used by a pass-two encoder
+context. The selected record must describe an I frame. This permits callers
+to encode independently decodable temporal ranges in parallel while every
+context uses the same global pass-two plan. The default is 0.
+
+@item pass2_frames @var{integer}
+Limit a segmented pass-two context to this many records beginning at
+@option{pass2_offset}. A value of 0, the default, uses all remaining pass-one
+records.
 @end table
 
 The encoder also supports FFmpeg's normal two-pass interface when a positive
@@ -3612,10 +3637,8 @@ pass 2. The standard @option{qmin} and @option{qmax} options constrain
 rate-control quantizers and default to 12 and 48.
 @option{rc_buffer_size} and @option{rc_init_occupancy} configure the pass-2
 rate-control reservoir.
-
 With @option{flags +psnr}, the encoder records component SSE in
 @code{AVCodecContext.error} and logs aggregate reconstruction SSE and PSNR.
-
 Without a positive bit rate, pass flags are ignored after a warning; an
 explicit @option{qp} is used directly, or quantizer 40 is used as the
 fallback.
diff --git a/libavcodec/Makefile b/libavcodec/Makefile
index 65038f4b0b..95d5224a28 100644
--- a/libavcodec/Makefile
+++ b/libavcodec/Makefile
@@ -546,8 +546,9 @@ OBJS-$(CONFIG_MLP_DECODER)             += mlpdec.o mlpdsp.o
 OBJS-$(CONFIG_MLP_ENCODER)             += mlpenc.o mlp.o
 OBJS-$(CONFIG_MMVIDEO_DECODER)         += mmvideo.o
 OBJS-$(CONFIG_MOBICLIP_DECODER)        += mobiclip.o mobiclipdata.o
-OBJS-$(CONFIG_MOBICLIP_ENCODER)        += mobiclipenc.o mobiclipencdsp.o \
-                                          mobiclipencintrapred.o mobiclipdata.o
+OBJS-$(CONFIG_MOBICLIP_ENCODER)        += mobiclipenc.o mobiclipenc_parallel.o \
+                                          mobiclipencdsp.o mobiclipencintrapred.o \
+                                          mobiclipdata.o executor.o
 OBJS-$(CONFIG_MOTIONPIXELS_DECODER)    += motionpixels.o
 OBJS-$(CONFIG_MOVTEXT_DECODER)         += movtextdec.o ass.o
 OBJS-$(CONFIG_MOVTEXT_ENCODER)         += movtextenc.o ass_split.o
diff --git a/libavcodec/mobiclipenc.c b/libavcodec/mobiclipenc.c
index 49672ea6fe..7566b64308 100644
--- a/libavcodec/mobiclipenc.c
+++ b/libavcodec/mobiclipenc.c
@@ -43,6 +43,7 @@
 #include "internal.h"
 #include "mathops.h"
 #include "mobiclipdata.h"
+#include "mobiclipenc_parallel.h"
 #include "mobiclipencdsp.h"
 #include "mobiclipencintrapred.h"
 #include "profiles.h"
@@ -193,10 +194,14 @@ typedef struct MobiClipEncContext {
     int rc_enabled;
     int common_cbr;
     int owns_stats_out;
+    int parallel_worker;
+    int temporal_ranges;
     int previous_qp;
     int64_t frame_count;
     int64_t last_key_frame;
     int64_t pass2_count;
+    int64_t pass2_offset;
+    int64_t pass2_frames;
     MobiPassFrame *pass2;
     MobiRateHistory rc_history[2];
     float rc_target_bits_per_frame;
@@ -220,6 +225,7 @@ typedef struct MobiClipEncContext {
     uint8_t intra_context[20];
     MobiClipEncDSPContext dsp;
     MobiClipEncIntraPredContext intra_pred;
+    FFMobiclipParallelContext *parallel;
     AVSliceThread *reference_threads;
     const AVFrame *reference_source;
     int reference_slot;
@@ -232,6 +238,13 @@ typedef struct MobiClipEncContext {
 static void build_reference_worker(void *priv, int jobnr, int threadnr,
                                    int nb_jobs, int nb_threads);
 
+void ff_mobiclip_mark_parallel_worker(AVCodecContext *avctx)
+{
+    MobiClipEncContext *s = avctx->priv_data;
+
+    s->parallel_worker = 1;
+}
+
 static uint64_t mobi_lambda(int qp)
 {
     return (uint64_t)(FFMAX(1.0, MOBI_LAMBDA_SCALE *
@@ -4087,6 +4100,34 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
 
     if (!avctx->thread_count)
         avctx->thread_count = FFMAX(av_cpu_count(), 1);
+    if (!s->parallel_worker && s->temporal_ranges) {
+        if (avctx->thread_count < 2) {
+            av_log(avctx, AV_LOG_ERROR,
+                   "Temporal range parallelism requires at least 2 threads\n");
+            return AVERROR(EINVAL);
+        }
+        if (avctx->flags & AV_CODEC_FLAG_RECON_FRAME) {
+            av_log(avctx, AV_LOG_ERROR,
+                   "Temporal range parallelism is incompatible with "
+                   "reconstructed-frame output\n");
+            return AVERROR(EINVAL);
+        }
+        if (pass_flags && avctx->bit_rate <= 0) {
+            av_log(avctx, AV_LOG_ERROR,
+                   "Temporal range parallelism with pass flags requires "
+                   "a positive bitrate\n");
+            return AVERROR(EINVAL);
+        }
+        if (pass_flags & AV_CODEC_FLAG_PASS1) {
+            avctx->stats_out = av_malloc(MOBI_PASS_STATS_SIZE);
+            if (!avctx->stats_out)
+                return AVERROR(ENOMEM);
+            s->owns_stats_out = 1;
+        }
+        avctx->bits_per_raw_sample = 8;
+        return ff_mobiclip_parallel_init(avctx, &s->parallel,
+                                         avctx->thread_count);
+    }
 
     for (int size_bits = 1; size_bits <= 4; size_bits++) {
         s->sad[size_bits - 1] =
@@ -4143,11 +4184,21 @@ static av_cold int mobiclip_encode_init(AVCodecContext *avctx)
         if (pass_flags & AV_CODEC_FLAG_PASS2) {
             if ((ret = setup_pass2(avctx)) < 0)
                 return ret;
-            if (!s->pass2[0].key) {
+            if (s->pass2_offset >= s->pass2_count ||
+                !s->pass2[s->pass2_offset].key) {
                 av_log(avctx, AV_LOG_ERROR,
-                       "MobiClip pass 1 must begin with an I frame\n");
+                       "MobiClip pass-2 offset must select an I frame\n");
                 return AVERROR(EINVAL);
             }
+            if (!s->pass2_frames)
+                s->pass2_frames = s->pass2_count - s->pass2_offset;
+            if (s->pass2_frames > s->pass2_count - s->pass2_offset) {
+                av_log(avctx, AV_LOG_ERROR,
+                       "MobiClip pass-2 range exceeds the pass-1 statistics\n");
+                return AVERROR(EINVAL);
+            }
+            s->rc_target_bits =
+                s->rc_target_bits / s->pass2_count * s->pass2_frames;
         } else {
             s->common_cbr = 1;
             if ((ret = setup_common_cbr(avctx)) < 0)
@@ -4186,6 +4237,12 @@ static av_cold int mobiclip_encode_close(AVCodecContext *avctx)
 {
     MobiClipEncContext *s = avctx->priv_data;
 
+    if (s->parallel) {
+        ff_mobiclip_parallel_free(&s->parallel);
+        if (s->owns_stats_out)
+            av_freep(&avctx->stats_out);
+        return 0;
+    }
     if (s->frame_count && (avctx->flags & AV_CODEC_FLAG_PSNR)) {
         long double samples = s->frame_count * (long double)avctx->width *
                               avctx->height * 3 / 2;
@@ -4201,11 +4258,11 @@ static av_cold int mobiclip_encode_close(AVCodecContext *avctx)
         av_log(avctx, AV_LOG_INFO,
                "MobiClip pass 2 wrote %.0f video bits for a %.0f-bit target\n",
                (double)s->rc_actual_bits, (double)s->rc_target_bits);
-        if (s->frame_count < s->pass2_count)
+        if (s->frame_count < s->pass2_frames)
             av_log(avctx, AV_LOG_WARNING,
                    "Pass 2 used only %" PRId64 " of %" PRId64
                    " pass-1 frame records\n",
-                   s->frame_count, s->pass2_count);
+                   s->frame_count, s->pass2_frames);
     }
     for (int i = 0; i < FF_ARRAY_ELEMS(s->reference); i++)
         av_frame_free(&s->reference[i]);
@@ -4275,9 +4332,10 @@ static int mobiclip_encode_frame(AVCodecContext *avctx, AVPacket *pkt,
         return 0;
     if (s->pass2) {
         const MobiPassFrame *pass;
-        int64_t pass_index = s->frame_count;
 
-        if (s->frame_count >= s->pass2_count) {
+        int64_t pass_index = s->pass2_offset + s->frame_count;
+
+        if (s->frame_count >= s->pass2_frames) {
             av_log(avctx, AV_LOG_ERROR,
                    "Pass 2 received more frames than pass 1\n");
             return AVERROR_INVALIDDATA;
@@ -4412,6 +4470,9 @@ static int mobiclip_receive_packet(AVCodecContext *avctx, AVPacket *pkt)
     int got_packet = 0;
     int ret;
 
+    if (s->parallel)
+        return ff_mobiclip_parallel_receive_packet(avctx, s->parallel, pkt);
+
     ret = ff_encode_get_frame(avctx, s->input);
     if (ret < 0)
         return ret;
@@ -4454,6 +4515,12 @@ static const AVOption mobiclip_options[] = {
       OFFSET(qp), AV_OPT_TYPE_INT, { .i64 = -1 }, -1, MOBI_MAX_QP, VE },
     { "max_frame_size", "set the maximum encoded frame size in bytes (0 disables)",
       OFFSET(max_frame_size), AV_OPT_TYPE_INT, { .i64 = 0 }, 0, INT_MAX, VE },
+    { "temporal_ranges", "enable high-latency temporal range parallelism",
+      OFFSET(temporal_ranges), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, VE },
+    { "pass2_offset", "set the first pass-2 frame index for segmented encoding",
+      OFFSET(pass2_offset), AV_OPT_TYPE_INT64, { .i64 = 0 }, 0, INT64_MAX, VE },
+    { "pass2_frames", "set the pass-2 frame count for segmented encoding",
+      OFFSET(pass2_frames), AV_OPT_TYPE_INT64, { .i64 = 0 }, 0, INT64_MAX, VE },
     { NULL },
 };
 
@@ -4479,7 +4546,8 @@ const FFCodec ff_mobiclip_encoder = {
     CODEC_LONG_NAME("MobiClip Video"),
     .p.type         = AVMEDIA_TYPE_VIDEO,
     .p.id           = AV_CODEC_ID_MOBICLIP,
-    .p.capabilities = AV_CODEC_CAP_DR1 | AV_CODEC_CAP_OTHER_THREADS |
+    .p.capabilities = AV_CODEC_CAP_DR1 | AV_CODEC_CAP_DELAY |
+                      AV_CODEC_CAP_OTHER_THREADS |
                       AV_CODEC_CAP_ENCODER_REORDERED_OPAQUE |
                       AV_CODEC_CAP_ENCODER_RECON_FRAME,
     .priv_data_size = sizeof(MobiClipEncContext),
diff --git a/libavcodec/mobiclipenc_parallel.c b/libavcodec/mobiclipenc_parallel.c
new file mode 100644
index 0000000000..b74d790a7b
--- /dev/null
+++ b/libavcodec/mobiclipenc_parallel.c
@@ -0,0 +1,695 @@
+/*
+ * MobiClip video encoder temporal threading
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "config.h"
+
+#include <inttypes.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "libavutil/avstring.h"
+#include "libavutil/common.h"
+#include "libavutil/mem.h"
+#include "libavutil/opt.h"
+#include "libavutil/rational.h"
+#include "libavutil/thread.h"
+
+#include "avcodec.h"
+#include "codec_par.h"
+#include "encode.h"
+#include "executor.h"
+#include "mobiclipenc_parallel.h"
+
+#define MOBI_PARALLEL_STATS_SIZE       80
+#define MOBI_RANGE_SECONDS             30
+#define MOBI_HISTOGRAM_COMPONENT_BINS 256
+#define MOBI_HISTOGRAM_BINS           (3 * MOBI_HISTOGRAM_COMPONENT_BINS)
+
+typedef struct MobiParallelJob {
+    FFTask task;
+    struct FFMobiclipParallelContext *owner;
+    struct MobiParallelJob *next;
+    AVFrame **frames;
+    AVPacket **packets;
+    char (*stats)[MOBI_PARALLEL_STATS_SIZE];
+    int64_t start_frame;
+    int capacity;
+    int frame_count;
+    int output_index;
+    int result;
+    int done;
+    int errors_applied;
+    uint64_t error[4];
+} MobiParallelJob;
+
+struct FFMobiclipParallelContext {
+    AVCodecContext *parent;
+    AVCodecParameters *parameters;
+    FFExecutor *executor;
+    AVMutex mutex;
+    AVCond cond;
+    int mutex_initialized;
+    int cond_initialized;
+    int temporal_threads;
+    int worker_threads;
+    int range_frames;
+    int tail_frames;
+    int max_submitted;
+    int submitted;
+    int input_eof;
+    int fatal_error;
+    int64_t pending_start;
+    int64_t pass2_base;
+    AVFrame *input;
+    AVFrame **pending;
+    int pending_count;
+    int pending_capacity;
+    int *distance;
+    int distance_count;
+    int distance_capacity;
+    int previous_histogram[MOBI_HISTOGRAM_BINS];
+    int have_previous_histogram;
+    MobiParallelJob *head;
+    MobiParallelJob *tail;
+};
+
+static void free_job(MobiParallelJob **jobp)
+{
+    MobiParallelJob *job;
+
+    if (!jobp || !*jobp)
+        return;
+    job = *jobp;
+    for (int i = 0; i < job->capacity; i++) {
+        av_frame_free(&job->frames[i]);
+        av_packet_free(&job->packets[i]);
+    }
+    av_freep(&job->frames);
+    av_freep(&job->packets);
+    av_freep(&job->stats);
+    av_freep(jobp);
+}
+
+static MobiParallelJob *alloc_job(FFMobiclipParallelContext *p,
+                                  int frame_count)
+{
+    MobiParallelJob *job = av_mallocz(sizeof(*job));
+
+    if (!job)
+        return NULL;
+    job->frames  = av_calloc(frame_count, sizeof(*job->frames));
+    job->packets = av_calloc(frame_count, sizeof(*job->packets));
+    if (p->parent->flags & AV_CODEC_FLAG_PASS1)
+        job->stats = av_calloc(frame_count, sizeof(*job->stats));
+    if (!job->frames || !job->packets ||
+        ((p->parent->flags & AV_CODEC_FLAG_PASS1) && !job->stats)) {
+        free_job(&job);
+        return NULL;
+    }
+    job->owner       = p;
+    job->capacity    = frame_count;
+    job->frame_count = frame_count;
+    job->start_frame = p->pending_start;
+    return job;
+}
+
+static int format_global_stats(char *dst, size_t dst_size, const char *src,
+                               int64_t frame, int64_t expected_local_frame)
+{
+    int64_t local_frame, bits;
+    int qp, intra, inter;
+    char type, tail;
+    int matched;
+
+    matched = sscanf(src, "%" SCNd64 " %c %d %" SCNd64 " %d %d %c",
+                     &local_frame, &type, &qp, &bits, &intra, &inter, &tail);
+    if (matched != 6 || local_frame != expected_local_frame ||
+        (type != 'i' && type != 'p') ||
+        qp < 0 || bits < 0 || intra < 0 || inter < 0)
+        return AVERROR_INVALIDDATA;
+    if (snprintf(dst, dst_size, "%" PRId64 " %c %d %" PRId64 " %d %d\n",
+                 frame, type, qp, bits, intra, inter) >= dst_size)
+        return AVERROR(ENOSPC);
+    return 0;
+}
+
+static int open_worker_context(FFMobiclipParallelContext *p,
+                               MobiParallelJob *job,
+                               AVCodecContext **childp)
+{
+    AVCodecContext *child;
+    AVCodecContext *parent = p->parent;
+    int ret;
+
+    child = avcodec_alloc_context3(parent->codec);
+    if (!child)
+        return AVERROR(ENOMEM);
+    ret = avcodec_parameters_to_context(child, p->parameters);
+    if (ret < 0)
+        goto fail;
+    ret = av_opt_copy(child, parent);
+    if (ret < 0)
+        goto fail;
+    if (parent->codec->priv_class) {
+        ret = av_opt_copy(child->priv_data, parent->priv_data);
+        if (ret < 0)
+            goto fail;
+    }
+
+    child->thread_count       = p->worker_threads;
+    child->active_thread_type = 0;
+    child->opaque             = parent->opaque;
+    child->get_encode_buffer  = parent->get_encode_buffer;
+    child->execute            = parent->execute;
+    child->execute2           = parent->execute2;
+    child->stats_in           = parent->stats_in;
+    child->time_base          = parent->time_base;
+    child->framerate          = parent->framerate;
+    child->pkt_timebase       = parent->pkt_timebase;
+    ff_mobiclip_mark_parallel_worker(child);
+
+    if (parent->flags & AV_CODEC_FLAG_PASS2) {
+        ret = av_opt_set_int(child->priv_data, "pass2_offset",
+                             p->pass2_base + job->start_frame, 0);
+        if (ret < 0)
+            goto fail;
+        ret = av_opt_set_int(child->priv_data, "pass2_frames",
+                             job->frame_count, 0);
+        if (ret < 0)
+            goto fail;
+    }
+    ret = avcodec_open2(child, parent->codec, NULL);
+    if (ret < 0)
+        goto fail;
+    *childp = child;
+    return 0;
+
+fail:
+    avcodec_free_context(&child);
+    return ret;
+}
+
+static int encode_job(MobiParallelJob *job)
+{
+    FFMobiclipParallelContext *p = job->owner;
+    AVCodecContext *child = NULL;
+    int ret;
+
+    ret = open_worker_context(p, job, &child);
+    if (ret < 0)
+        return ret;
+
+    for (int i = 0; i < job->frame_count; i++) {
+        AVPacket *pkt = av_packet_alloc();
+
+        if (!pkt) {
+            ret = AVERROR(ENOMEM);
+            goto end;
+        }
+        ret = avcodec_send_frame(child, job->frames[i]);
+        av_frame_free(&job->frames[i]);
+        if (ret < 0) {
+            av_packet_free(&pkt);
+            goto end;
+        }
+        ret = avcodec_receive_packet(child, pkt);
+        if (ret < 0) {
+            av_packet_free(&pkt);
+            goto end;
+        }
+        job->packets[i] = pkt;
+        if (job->stats) {
+            if (!child->stats_out || !child->stats_out[0]) {
+                ret = AVERROR_INVALIDDATA;
+                goto end;
+            }
+            ret = format_global_stats(job->stats[i],
+                                      sizeof(job->stats[i]),
+                                      child->stats_out,
+                                      job->start_frame + i, i);
+            if (ret < 0)
+                goto end;
+        }
+    }
+    for (int plane = 0; plane < 4; plane++)
+        job->error[plane] = child->error[plane];
+    ret = 0;
+
+end:
+    avcodec_free_context(&child);
+    return ret;
+}
+
+static int run_job(FFTask *task, void *local_context, void *user_data)
+{
+    MobiParallelJob *job = (MobiParallelJob *)task;
+    FFMobiclipParallelContext *p = user_data;
+
+    job->result = encode_job(job);
+    ff_mutex_lock(&p->mutex);
+    job->done = 1;
+    ff_cond_broadcast(&p->cond);
+    ff_mutex_unlock(&p->mutex);
+    return 0;
+}
+
+static int ensure_pending_capacity(FFMobiclipParallelContext *p, int count)
+{
+    AVFrame **pending;
+    int *distance;
+    int capacity = p->pending_capacity;
+
+    if (count > capacity) {
+        capacity = FFMAX(64, capacity);
+        while (capacity < count) {
+            if (capacity > INT_MAX / 2)
+                return AVERROR(ENOMEM);
+            capacity *= 2;
+        }
+        pending = av_realloc_array(p->pending, capacity,
+                                   sizeof(*p->pending));
+        if (!pending)
+            return AVERROR(ENOMEM);
+        p->pending = pending;
+        p->pending_capacity = capacity;
+    }
+
+    if (count - 1 <= p->distance_capacity)
+        return 0;
+    distance = av_realloc_array(p->distance, p->pending_capacity - 1,
+                                sizeof(*p->distance));
+    if (!distance)
+        return AVERROR(ENOMEM);
+    p->distance = distance;
+    p->distance_capacity = p->pending_capacity - 1;
+    return 0;
+}
+
+static void build_histogram(const AVFrame *frame, int histogram[768])
+{
+    int width  = frame->width;
+    int height = frame->height;
+
+    memset(histogram, 0, MOBI_HISTOGRAM_BINS * sizeof(*histogram));
+    for (int y = 0; y < height; y++) {
+        const uint8_t *src = frame->data[0] + y * frame->linesize[0];
+
+        for (int x = 0; x < width; x++)
+            histogram[src[x]]++;
+    }
+    width  = AV_CEIL_RSHIFT(width, 1);
+    height = AV_CEIL_RSHIFT(height, 1);
+    for (int plane = 1; plane < 3; plane++) {
+        int *component = histogram + plane * MOBI_HISTOGRAM_COMPONENT_BINS;
+
+        for (int y = 0; y < height; y++) {
+            const uint8_t *src = frame->data[plane] +
+                                 y * frame->linesize[plane];
+
+            for (int x = 0; x < width; x++)
+                component[src[x]] += 4;
+        }
+    }
+}
+
+static int histogram_distance(const int current[768],
+                              const int previous[768])
+{
+    int distance = 0;
+
+    for (int i = 0; i < MOBI_HISTOGRAM_BINS; i++)
+        distance += FFABS(current[i] - previous[i]);
+    return distance;
+}
+
+static int add_input_frame(FFMobiclipParallelContext *p)
+{
+    int histogram[MOBI_HISTOGRAM_BINS];
+    AVFrame *frame;
+    int ret;
+
+    ret = ensure_pending_capacity(p, p->pending_count + 1);
+    if (ret < 0)
+        return ret;
+    build_histogram(p->input, histogram);
+    if (p->have_previous_histogram)
+        p->distance[p->distance_count++] =
+            histogram_distance(histogram, p->previous_histogram);
+    memcpy(p->previous_histogram, histogram, sizeof(histogram));
+    p->have_previous_histogram = 1;
+
+    frame = av_frame_alloc();
+    if (!frame)
+        return AVERROR(ENOMEM);
+    av_frame_move_ref(frame, p->input);
+    p->pending[p->pending_count++] = frame;
+    return 0;
+}
+
+/*
+ * Return the selected boundary relative to the start of the pending range.
+ * Arithmetic and tie handling intentionally follow the bitstream producer:
+ * the product wraps to 32 bits and equal scores keep the earlier boundary.
+ */
+static int select_boundary(const FFMobiclipParallelContext *p)
+{
+    uint32_t best_score = 0;
+    int best = 0;
+    int count = FFMIN(p->distance_count, p->range_frames);
+
+    for (int i = 0; i < count; i++) {
+        uint32_t boundary = i + 1;
+        uint32_t score = ((uint32_t)p->distance[i] + 1U) * boundary;
+
+        if (score > best_score) {
+            best_score = score;
+            best = boundary;
+        }
+    }
+    return best;
+}
+
+static int submit_prefix(FFMobiclipParallelContext *p, int frame_count)
+{
+    MobiParallelJob *job;
+
+    if (p->submitted >= p->max_submitted)
+        return AVERROR(EAGAIN);
+    if (frame_count <= 0 || frame_count > p->pending_count)
+        return AVERROR_BUG;
+
+    job = alloc_job(p, frame_count);
+    if (!job)
+        return AVERROR(ENOMEM);
+    memcpy(job->frames, p->pending, frame_count * sizeof(*job->frames));
+    memmove(p->pending, p->pending + frame_count,
+            (p->pending_count - frame_count) * sizeof(*p->pending));
+    p->pending_count -= frame_count;
+
+    if (frame_count <= p->distance_count) {
+        memmove(p->distance, p->distance + frame_count,
+                (p->distance_count - frame_count) * sizeof(*p->distance));
+        p->distance_count -= frame_count;
+    } else {
+        p->distance_count = 0;
+    }
+
+    p->pending_start += frame_count;
+    if (!p->head)
+        p->head = job;
+    else
+        p->tail->next = job;
+    p->tail = job;
+    p->submitted++;
+    av_log(p->parent, AV_LOG_VERBOSE,
+           "MobiClip range: start=%" PRId64 ", frames=%d\n",
+           job->start_frame, frame_count);
+    if (p->executor) {
+        ff_executor_execute(p->executor, &job->task);
+    } else {
+        job->result = encode_job(job);
+        job->done = 1;
+    }
+    return 0;
+}
+
+/*
+ * Emit every range whose boundary can no longer be changed by future frames.
+ * At EOF the remaining partial search window is resolved and the final range
+ * is always submitted.
+ */
+static int plan_ranges(FFMobiclipParallelContext *p)
+{
+    for (;;) {
+        int boundary;
+
+        if (!p->pending_count)
+            return 0;
+        if (!p->input_eof && p->distance_count < p->range_frames)
+            return 0;
+
+        boundary = select_boundary(p);
+        if (!p->input_eof) {
+            if (!boundary ||
+                p->pending_count - boundary < p->tail_frames)
+                return 0;
+        } else if (!boundary ||
+                   p->pending_count - boundary < p->tail_frames) {
+            boundary = p->pending_count;
+        }
+
+        {
+            int ret = submit_prefix(p, boundary);
+
+            if (ret < 0)
+                return ret;
+        }
+    }
+}
+
+/*
+ * Return the next ordered packet. When wait is set, block only for the first
+ * submitted range; later ranges may complete in any order.
+ */
+static int take_packet(FFMobiclipParallelContext *p, AVPacket *pkt, int wait)
+{
+    AVCodecContext *avctx = p->parent;
+
+    for (;;) {
+        MobiParallelJob *job;
+        int ret;
+
+        if (p->executor)
+            ff_mutex_lock(&p->mutex);
+        job = p->head;
+        while (p->executor && job && !job->done && wait)
+            ff_cond_wait(&p->cond, &p->mutex);
+        if (!job || !job->done) {
+            int empty_eof = !job && p->input_eof && !p->pending_count;
+
+            if (p->executor)
+                ff_mutex_unlock(&p->mutex);
+            return empty_eof ? AVERROR_EOF : AVERROR(EAGAIN);
+        }
+        ret = job->result;
+        if (ret < 0) {
+            p->fatal_error = ret;
+            if (p->executor)
+                ff_mutex_unlock(&p->mutex);
+            return ret;
+        }
+        if (!job->errors_applied) {
+            for (int plane = 0; plane < 4; plane++)
+                avctx->error[plane] += job->error[plane];
+            job->errors_applied = 1;
+        }
+        if (job->output_index < job->frame_count) {
+            int index = job->output_index++;
+
+            av_packet_move_ref(pkt, job->packets[index]);
+            if (job->stats)
+                av_strlcpy(avctx->stats_out, job->stats[index],
+                           MOBI_PARALLEL_STATS_SIZE);
+            if (p->executor)
+                ff_mutex_unlock(&p->mutex);
+            return 0;
+        }
+
+        p->head = job->next;
+        if (!p->head)
+            p->tail = NULL;
+        p->submitted--;
+        if (p->executor)
+            ff_mutex_unlock(&p->mutex);
+        free_job(&job);
+    }
+}
+
+int ff_mobiclip_parallel_receive_packet(AVCodecContext *avctx,
+                                        FFMobiclipParallelContext *p,
+                                        AVPacket *pkt)
+{
+    int ret;
+
+    if (p->fatal_error)
+        return p->fatal_error;
+
+    for (;;) {
+        ret = plan_ranges(p);
+        if (ret == AVERROR(EAGAIN)) {
+            ret = take_packet(p, pkt, 1);
+            if (ret != AVERROR(EAGAIN))
+                return ret;
+            continue;
+        }
+        if (ret < 0)
+            return ret;
+
+        if (p->input_eof) {
+            if (!p->pending_count && !p->submitted)
+                return AVERROR_EOF;
+            return take_packet(p, pkt, 1);
+        }
+
+        ret = ff_encode_get_frame(avctx, p->input);
+        if (ret == 0) {
+            ret = add_input_frame(p);
+            if (ret < 0)
+                return ret;
+            ret = plan_ranges(p);
+            if (ret == AVERROR(EAGAIN))
+                return take_packet(p, pkt, 1);
+            if (ret < 0)
+                return ret;
+            return take_packet(p, pkt, 0);
+        }
+        if (ret == AVERROR(EAGAIN))
+            return take_packet(p, pkt, 0);
+        if (ret == AVERROR_EOF) {
+            p->input_eof = 1;
+            continue;
+        }
+        return ret;
+    }
+}
+
+av_cold int ff_mobiclip_parallel_init(AVCodecContext *avctx,
+                                      FFMobiclipParallelContext **parallel,
+                                      int thread_count)
+{
+    FFMobiclipParallelContext *p;
+    FFTaskCallbacks callbacks = {
+        .local_context_size = 1,
+        .priorities         = 1,
+        .run                = run_job,
+    };
+    AVRational frame_rate = avctx->framerate;
+    int64_t pass2_base = 0;
+    int64_t range_frames;
+    int ret;
+
+    if (thread_count < 2)
+        return AVERROR(EINVAL);
+    if (frame_rate.num <= 0 || frame_rate.den <= 0) {
+        if (avctx->time_base.num <= 0 || avctx->time_base.den <= 0)
+            return AVERROR(EINVAL);
+        frame_rate = av_inv_q(avctx->time_base);
+    }
+    range_frames = av_rescale_rnd(MOBI_RANGE_SECONDS, frame_rate.num,
+                                  frame_rate.den, AV_ROUND_NEAR_INF);
+    if (range_frames < 1 || range_frames > INT_MAX)
+        return AVERROR(EINVAL);
+
+    p = av_mallocz(sizeof(*p));
+    if (!p)
+        return AVERROR(ENOMEM);
+    p->parent           = avctx;
+    p->worker_threads   = thread_count < 4 ? 1 : 2;
+    p->temporal_threads = thread_count / p->worker_threads;
+    p->range_frames     = range_frames;
+    p->tail_frames      = range_frames / 4;
+    p->max_submitted    = p->temporal_threads > 1 ?
+                          p->temporal_threads + 1 : 1;
+    callbacks.user_data = p;
+
+    p->parameters = avcodec_parameters_alloc();
+    p->input      = av_frame_alloc();
+    if (!p->parameters || !p->input) {
+        ret = AVERROR(ENOMEM);
+        goto fail;
+    }
+    ret = avcodec_parameters_from_context(p->parameters, avctx);
+    if (ret < 0)
+        goto fail;
+    if (avctx->flags & AV_CODEC_FLAG_PASS2) {
+        ret = av_opt_get_int(avctx->priv_data, "pass2_offset", 0, &pass2_base);
+        if (ret < 0)
+            goto fail;
+        p->pass2_base = pass2_base;
+    }
+    if (p->temporal_threads > 1) {
+        if (ff_mutex_init(&p->mutex, NULL)) {
+            ret = AVERROR(ENOMEM);
+            goto fail;
+        }
+        p->mutex_initialized = 1;
+        if (ff_cond_init(&p->cond, NULL)) {
+            ret = AVERROR(ENOMEM);
+            goto fail;
+        }
+        p->cond_initialized = 1;
+        p->executor = ff_executor_alloc(&callbacks, p->temporal_threads);
+        if (!p->executor) {
+            ret = AVERROR(ENOMEM);
+            goto fail;
+        }
+    }
+
+    if (p->executor) {
+        av_log(avctx, AV_LOG_VERBOSE,
+               "Using %d temporal workers with %d threads each and a "
+               "%d-frame range window (%d-thread budget)\n",
+               p->temporal_threads, p->worker_threads,
+               p->range_frames, thread_count);
+    } else {
+        av_log(avctx, AV_LOG_VERBOSE,
+               "Using the serial range scheduler with a %d-frame window\n",
+               p->range_frames);
+    }
+    *parallel = p;
+    return 0;
+
+fail:
+    ff_mobiclip_parallel_free(&p);
+    return ret;
+}
+
+void ff_mobiclip_parallel_free(FFMobiclipParallelContext **parallel)
+{
+    FFMobiclipParallelContext *p;
+    MobiParallelJob *job;
+
+    if (!parallel || !*parallel)
+        return;
+    p = *parallel;
+    ff_executor_free(&p->executor);
+    job = p->head;
+    while (job) {
+        MobiParallelJob *next = job->next;
+
+        free_job(&job);
+        job = next;
+    }
+    for (int i = 0; i < p->pending_count; i++)
+        av_frame_free(&p->pending[i]);
+    av_freep(&p->pending);
+    av_freep(&p->distance);
+    av_frame_free(&p->input);
+    avcodec_parameters_free(&p->parameters);
+    if (p->cond_initialized)
+        ff_cond_destroy(&p->cond);
+    if (p->mutex_initialized)
+        ff_mutex_destroy(&p->mutex);
+    av_freep(parallel);
+}
diff --git a/libavcodec/mobiclipenc_parallel.h b/libavcodec/mobiclipenc_parallel.h
new file mode 100644
index 0000000000..4d31e8d02b
--- /dev/null
+++ b/libavcodec/mobiclipenc_parallel.h
@@ -0,0 +1,42 @@
+/*
+ * MobiClip video encoder temporal threading
+ *
+ * Copyright (c) 2026 Martijn Brouwer
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#ifndef AVCODEC_MOBICLIPENC_PARALLEL_H
+#define AVCODEC_MOBICLIPENC_PARALLEL_H
+
+#include "libavutil/attributes.h"
+
+#include "avcodec.h"
+#include "packet.h"
+
+typedef struct FFMobiclipParallelContext FFMobiclipParallelContext;
+
+av_cold int ff_mobiclip_parallel_init(AVCodecContext *avctx,
+                                      FFMobiclipParallelContext **parallel,
+                                      int thread_count);
+int ff_mobiclip_parallel_receive_packet(AVCodecContext *avctx,
+                                        FFMobiclipParallelContext *parallel,
+                                        AVPacket *pkt);
+void ff_mobiclip_parallel_free(FFMobiclipParallelContext **parallel);
+void ff_mobiclip_mark_parallel_worker(AVCodecContext *avctx);
+
+#endif /* AVCODEC_MOBICLIPENC_PARALLEL_H */
diff --git a/tests/fate/mobiclip.mak b/tests/fate/mobiclip.mak
index c8c38b6b9a..75b79b1baa 100644
--- a/tests/fate/mobiclip.mak
+++ b/tests/fate/mobiclip.mak
@@ -21,6 +21,8 @@ FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS)) += \
     fate-mobiclip-moc5-certificate-only
 fate-mobiclip-moc5-roundtrip: CMD = mobiclip_moc5_roundtrip 128 80 25 12 4 20
 fate-mobiclip-moc5-roundtrip: CMP = null
+fate-mobiclip-moc5-direct-motion: CMD = mobiclip_moc5_direct_motion
+fate-mobiclip-moc5-direct-motion: CMP = null
 fate-mobiclip-moc5-intra-modes: CMD = mobiclip_moc5_intra_modes
 fate-mobiclip-moc5-intra-modes: CMP = null
 fate-mobiclip-moc5-qp-lambda: CMD = mobiclip_moc5_qp_lambda
@@ -31,6 +33,8 @@ fate-mobiclip-moc5-deferred-intra8-selector: CMD = mobiclip_moc5_deferred_intra8
 fate-mobiclip-moc5-deferred-intra8-selector: CMP = null
 fate-mobiclip-moc5-zero-mask-intra-split: CMD = mobiclip_moc5_zero_mask_intra_split
 fate-mobiclip-moc5-zero-mask-intra-split: CMP = null
+fate-mobiclip-moc5-multiref: CMD = mobiclip_moc5_multiref
+fate-mobiclip-moc5-multiref: CMP = null
 fate-mobiclip-moc5-partitions: CMD = mobiclip_moc5_partitions
 fate-mobiclip-moc5-partitions: CMP = null
 fate-mobiclip-moc5-nested-partitions: CMD = mobiclip_moc5_nested_partitions
@@ -54,10 +58,6 @@ FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_ROUNDTRIP_DEPS) COLOR_FILTER)
     fate-mobiclip-moc5-p-intra \
     fate-mobiclip-moc5-scene-retry \
     fate-mobiclip-moc5-adaptive-keyframes
-fate-mobiclip-moc5-direct-motion: CMD = mobiclip_moc5_direct_motion
-fate-mobiclip-moc5-direct-motion: CMP = null
-fate-mobiclip-moc5-multiref: CMD = mobiclip_moc5_multiref
-fate-mobiclip-moc5-multiref: CMP = null
 fate-mobiclip-moc5-p-intra: CMD = mobiclip_moc5_p_intra
 fate-mobiclip-moc5-p-intra: CMP = null
 fate-mobiclip-moc5-scene-retry: CMD = mobiclip_moc5_scene_retry
@@ -81,6 +81,14 @@ fate-mobiclip-moc5-onepass-cbr: CMP = null
 fate-mobiclip-moc5-twopass-high-qp: CMD = mobiclip_moc5_twopass_high_qp
 fate-mobiclip-moc5-twopass-high-qp: CMP = null
 
+FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_TWOPASS_DEPS) NULL_MUXER) += \
+    fate-mobiclip-moc5-internal-threaded-twopass \
+    fate-mobiclip-moc5-segmented-twopass
+fate-mobiclip-moc5-internal-threaded-twopass: CMD = mobiclip_moc5_internal_threaded_twopass
+fate-mobiclip-moc5-internal-threaded-twopass: CMP = null
+fate-mobiclip-moc5-segmented-twopass: CMD = mobiclip_moc5_segmented_twopass
+fate-mobiclip-moc5-segmented-twopass: CMP = null
+
 FATE_MOBICLIP_MOC5-$(call ALLYES, $(MOBICLIP_MOC5_TWOPASS_DEPS) COLOR_FILTER) += \
     fate-mobiclip-moc5-twopass
 fate-mobiclip-moc5-twopass: CMD = mobiclip_moc5_twopass
diff --git a/tests/mobiclip-fate.sh b/tests/mobiclip-fate.sh
index 82ae841673..deeb545dc2 100644
--- a/tests/mobiclip-fate.sh
+++ b/tests/mobiclip-fate.sh
@@ -1289,6 +1289,161 @@ mobiclip_moc5_twopass(){
     }
 }
 
+# Exercise the range contract used by callers that run independently
+# decodable temporal segments in parallel. Each pass-one context numbers its
+# records locally; the coordinator rebases them into one global plan, and the
+# second pass selects only the second half.
+mobiclip_moc5_segmented_twopass(){
+    width=128
+    height=80
+    rate=25
+    segment_frames=6
+    frames=$((segment_frames * 2))
+    bitrate=250000
+    frame_size=$((width * height * 3 / 2))
+
+    source="${outdir}/${test}.source.yuv"
+    first="${outdir}/${test}.first.yuv"
+    second="${outdir}/${test}.second.yuv"
+    encoded="${outdir}/${test}.mo"
+    decoded="${outdir}/${test}.decoded.yuv"
+    first_log="${outdir}/${test}.first"
+    second_log="${outdir}/${test}.second"
+    global_log="${outdir}/${test}.global"
+    cleanfiles="$cleanfiles $source $first $second $encoded $decoded"
+    cleanfiles="$cleanfiles ${first_log}-0.log ${second_log}-0.log ${global_log}-0.log"
+    tsource=$(target_path "$source")
+    tfirst=$(target_path "$first")
+    tsecond=$(target_path "$second")
+    tencoded=$(target_path "$encoded")
+    tdecoded=$(target_path "$decoded")
+    tfirst_log=$(target_path "$first_log")
+    tsecond_log=$(target_path "$second_log")
+    tglobal_log=$(target_path "$global_log")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v rawvideo \
+        -f rawvideo -y "$tsource" || return
+    dd if="$source" of="$first" bs="$frame_size" \
+        count="$segment_frames" 2>/dev/null || return
+    dd if="$source" of="$second" bs="$frame_size" \
+        skip="$segment_frames" count="$segment_frames" 2>/dev/null || return
+
+    ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+        -video_size "${width}x${height}" -framerate "$rate" -i "$tfirst" \
+        -frames:v "$segment_frames" -an -c:v mobiclip -b:v "$bitrate" \
+        -pass 1 -passlogfile "$tfirst_log" -f null - || return
+    ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+        -video_size "${width}x${height}" -framerate "$rate" -i "$tsecond" \
+        -frames:v "$segment_frames" -an -c:v mobiclip -b:v "$bitrate" \
+        -pass 1 -passlogfile "$tsecond_log" -f null - || return
+
+    cat "${first_log}-0.log" > "${global_log}-0.log" || return
+    awk -v offset="$segment_frames" \
+        '{$1 += offset; print}' "${second_log}-0.log" \
+        >> "${global_log}-0.log" || return
+
+    ffmpeg -auto_conversion_filters -f rawvideo -pix_fmt yuv420p \
+        -video_size "${width}x${height}" -framerate "$rate" -i "$tsecond" \
+        -frames:v "$segment_frames" -an -c:v mobiclip -b:v "$bitrate" \
+        -pass 2 -passlogfile "$tglobal_log" \
+        -pass2_offset "$segment_frames" -pass2_frames "$segment_frames" \
+        -f mobiclip_mo -y "$tencoded" || return
+
+    sh "${base}/moc5check.sh" "$encoded" "$width" "$height" \
+        $((rate * 256)) "$segment_frames" "$segment_frames" 0 || return
+    ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+        -frames:v "$segment_frames" -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+    [ "$(wc -c < "$decoded")" -eq \
+        $((frame_size * segment_frames)) ] || {
+        echo "segmented two-pass output did not decode every frame" >&2
+        return 1
+    }
+}
+
+# Exercise the encoder-owned range scheduler. Both passes use one ordinary
+# input and output stream, and two temporal worker budgets must be exact.
+mobiclip_moc5_internal_threaded_twopass(){
+    width=128
+    height=80
+    rate=1
+    frames=40
+    gop=10
+    threads=4
+    comparison_threads=2
+    bitrate=250000
+    frame_size=$((width * height * 3 / 2))
+
+    passlog="${outdir}/${test}.stats"
+    encoded="${outdir}/${test}.mo"
+    serial_passlog="${outdir}/${test}.serial.stats"
+    serial_encoded="${outdir}/${test}.serial.mo"
+    decoded="${outdir}/${test}.decoded.yuv"
+    cleanfiles="$cleanfiles ${passlog}-0.log $encoded"
+    cleanfiles="$cleanfiles ${serial_passlog}-0.log $serial_encoded $decoded"
+    tpasslog=$(target_path "$passlog")
+    tencoded=$(target_path "$encoded")
+    tserial_passlog=$(target_path "$serial_passlog")
+    tserial_encoded=$(target_path "$serial_encoded")
+    tdecoded=$(target_path "$decoded")
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -an -c:v mobiclip -threads:v "$threads" \
+        -temporal_ranges 1 \
+        -g "$gop" -b:v "$bitrate" -pass 1 -passlogfile "$tpasslog" \
+        -f null - || return
+    [ "$(wc -l < "${passlog}-0.log")" -eq "$frames" ] || {
+        echo "internally threaded pass 1 did not emit one record per frame" >&2
+        return 1
+    }
+    awk '$1 != NR - 1 { exit 1 }' "${passlog}-0.log" || {
+        echo "internally threaded pass 1 records are not globally ordered" >&2
+        return 1
+    }
+
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -an -c:v mobiclip -threads:v "$threads" \
+        -temporal_ranges 1 \
+        -g "$gop" -b:v "$bitrate" -pass 2 -passlogfile "$tpasslog" \
+        -f mobiclip_mo -y "$tencoded" || return
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -an -c:v mobiclip \
+        -threads:v "$comparison_threads" -temporal_ranges 1 \
+        -g "$gop" -b:v "$bitrate" -pass 1 \
+        -passlogfile "$tserial_passlog" -f null - || return
+    ffmpeg -auto_conversion_filters -f lavfi \
+        -i "testsrc2=size=${width}x${height}:rate=${rate}" \
+        -frames:v "$frames" -an -c:v mobiclip \
+        -threads:v "$comparison_threads" -temporal_ranges 1 \
+        -g "$gop" -b:v "$bitrate" -pass 2 \
+        -passlogfile "$tserial_passlog" \
+        -f mobiclip_mo -y "$tserial_encoded" || return
+
+    cmp "${passlog}-0.log" "${serial_passlog}-0.log" || {
+        echo "temporal worker budget changed pass-one statistics" >&2
+        return 1
+    }
+    cmp "$encoded" "$serial_encoded" || {
+        echo "temporal worker budget changed the encoded stream" >&2
+        return 1
+    }
+
+    sh "${base}/moc5check.sh" "$encoded" "$width" "$height" \
+        $((rate * 256)) "$frames" "$gop" 0 || return
+    ffmpeg -auto_conversion_filters -i "$tencoded" -map 0:v:0 \
+        -frames:v "$frames" -pix_fmt yuv420p -c:v rawvideo \
+        -fps_mode passthrough -f rawvideo -y "$tdecoded" || return
+    [ "$(wc -c < "$decoded")" -eq $((frame_size * frames)) ] || {
+        echo "internally threaded two-pass output did not decode every frame" >&2
+        return 1
+    }
+}
+
 # Ensure pass-two statistics accept the complete six-bit quantizer range,
 # including values above the default rate-control maximum.
 mobiclip_moc5_twopass_high_qp(){
-- 
2.52.0

_______________________________________________
ffmpeg-devel mailing list -- [email protected]
To unsubscribe send an email to [email protected]