/* ============================================================================
 *  Tab5 内蔵カメラ サンプル（カメラ単体版）
 *  Tab5_Camera_Basic_20261106.ino        (M5Stack Tab5 / Arduino-ESP32)
 *
 *  ── これは何か ────────────────────────────────────────────────────────
 *   M5Stack Tab5 の内蔵カメラ（MIPI-CSI / SC202CS）を Arduino 環境で
 *   「動かして、見る」ところだけに絞ったサンプルです。
 *
 *     1) カメラ映像を 5インチ画面（1280x720）に表示する
 *     2) 表示モードを切り替える（生 / バイト入替 / 明暗伸長）
 *     3) 常に画面の左半分を白くして照明にし、右半分にカメラ映像を出す
 *        （暗い場所で、白い面が「証明写真の背景」や「手元の明かり」になる）
 *     4) 画面全体を白くするライトモード（シリアル 'l'）
 *     5) 露出時間とゲインを手で動かす（AE のハンチング対策の足がかり）
 *
 *   タッチは読みません（M5.update() を呼びません）。内蔵I2C をカメラの
 *   SCCB と取り合いになり、AE 制御が失敗して fps も落ちるためです。
 *
 *   現状ではI2Cと画面描画の性能に影響するため、本ライブラリとしては、
 *   二次元コードの読み取りサンプル部分を抹消しています。
 *
 *  ── 動作確認環境 ──────────────────────────────────────────────────────
 *   Arduino-ESP32 コア : 3.3.12（3.3.11 以降で可）
 *   ボード             : M5Stack Tab5
 *   PSRAM              : 有効（M5Unified が使用。本スケッチ自身は内蔵RAMのみ）
 *   ライブラリ         : M5Unified / ESP_Video（コア同梱）
 *
 *  ── コードを読む順番 ──────────────────────────────────────────────────
 *   【1】XCLK       … 必ず M5.begin() より先に設定する（後だと LEDC が衝突）
 *   【2】M5Unified 開始 / バックライトの救済
 *   【3】カメラ給電  … IOエキスパンダ 0x43 の E1.P6（CAM_RST）を解放する
 *   【4】ESP_Video 初期化 … dont_init_ldo = true が必須
 *   【5】撮影ループ  … captureBuffer() → 表示 → バッファは自動で返る
 *   【6】画面表示    … 16ラインずつ内蔵RAMを経由して pushImage
 *   【7】ライトモード… 画面を真っ白にして照明に使う
 *   【8】露出・ゲイン… 実験的。戻り値と画面を見ながら追い込む
 *
 *  ── ライセンス ────────────────────────────────────────────────────────
 *   このスケッチ : サンプルとして自由に改変・再配布できます（無保証）
 *   M5Unified / ESP_Video : それぞれのライセンスに従ってください
 * ==========================================================================*/

#include <Arduino.h>
#include "sdkconfig.h"

/* ── ESP_Video のビルド設定をスケッチ側から保証する ─────────────────────
 * ボード定義の sdkconfig に MIPI-CSI の項目が無い環境でも
 * ESP_Video が「MIPI-CSI あり / DVP なし / ESP32-P4」でコンパイルされる
 * ように、ここで定義します。この4ブロックは消さないでください。      */
#ifndef CONFIG_ESP_VIDEO_ENABLE_MIPI_CSI_VIDEO_DEVICE
#define CONFIG_ESP_VIDEO_ENABLE_MIPI_CSI_VIDEO_DEVICE 1
#endif
#ifndef CONFIG_ESP_VIDEO_ENABLE_DVP_VIDEO_DEVICE
#define CONFIG_ESP_VIDEO_ENABLE_DVP_VIDEO_DEVICE 0
#endif
#ifndef CONFIG_IDF_TARGET_ESP32P4
#define CONFIG_IDF_TARGET_ESP32P4 1
#endif

#include <driver/ledc.h>
#include <esp_log.h>
#include <ESP_Video.h>
#include <M5Unified.h>

/* ==========================================================================
 *  定数（Tab5 のハードウェア固定値）
 * ==========================================================================*/
static const int      PIN_XCLK = 36;      /* カメラのマスタークロック（MCLK） */
static const int      PIN_BL   = 22;      /* LCD バックライト（LEDA）         */
static const uint8_t  EXP_ADDR = 0x43;    /* IOエキスパンダ PI4IOE5V6408-1    */
static const uint32_t EXP_HZ   = 400000;  /* 内部I2C の速度                   */

#define CAM_W 1280
#define CAM_H  720

static ESPVideoClass           video;     /* ESP_Video 本体                   */
static ESPVideoCaptureDevClass cap;       /* キャプチャデバイス               */

/* ---- 画面転送用バッファ（内蔵RAM / 16ライン分） ----
 * PSRAM（カメラのフレームバッファ）から直接 DMA すると黒画面になることが
 * あるため、いったん内蔵RAMへコピーしてから pushImage() します。       */
#define LCHUNK 60      /* 60ライン単位。呼び出し回数を減らすと fps が上がります */
static uint16_t g_lbuf[CAM_W * LCHUNK];

/* ---- 表示モード ---- */
static const int MODE_N = 3;
static const char* MNAME[MODE_N] = {
  "COLOR", "COLOR byte-swap", "GRAY (auto gain)"
};

/* ---- ライトモード: 0=通常 / 1=白画面(照明) ---- */
static volatile int  g_light       = 0;
static volatile bool g_lightRedraw = false;

/* ---- 半分照明（常時 ON・固定） -------------------------------------------
 * 画面を左右に分割し、左半分を真っ白（照明）、右半分をカメラ映像にします。
 * タッチは読みません（M5.update() を呼ばない）。内蔵I2C がカメラの SCCB と
 * 同じ G31/G32 なので、触ると AE 制御が失敗し fps も落ちるためです。
 * 左右を入れ替えたいときは下の 1 を 0 にするか、シリアル 'r' で反転。 */
static volatile int  g_half       = 1;   /* 1=常に半分照明（固定） */
static volatile bool g_halfRedraw = true;
static int  g_lightLeft = 1;          /* 1=照明が左半分 / 0=右半分 */
#define HALF_W 640                    /* 半分の幅（1280/2） */
#define HALF_H 360                    /* プレビューの高さ（1280x720 を 1/2 縮小） */

/* ---- 露出・ゲイン（実験的） ----------------------------------------------
 * 値の単位はドライバ側の実装に依存します（マイクロ秒相当／行数相当など）。
 * 返り値が true でも「画面が変わらない」ことはあり得ます。その場合は
 * ドライバが AE（自動露出）を優先していると考えてください。
 * 数値はあくまで出発点です。実機で l/e/E/+/- を押しながら決めてください。 */
static const int32_t EXP_TAB[]  = { 100, 200, 500, 1000, 2000, 5000, 10000, 20000 };
static const int     EXP_N      = (int)(sizeof(EXP_TAB) / sizeof(EXP_TAB[0]));
static const int32_t GAIN_TAB[] = { 0, 1, 2, 4, 8, 16, 32, 64 };
static const int     GAIN_N     = (int)(sizeof(GAIN_TAB) / sizeof(GAIN_TAB[0]));

static int  g_expIdx  = 3;      /* EXP_TAB の添字（初期値 1000） */
static int  g_gainIdx = 0;      /* GAIN_TAB の添字（初期値 0）   */
static bool g_manual  = false;  /* 手動で動かしたか（表示用）    */

/* ==========================================================================
 *  【1】XCLK（カメラのマスタークロック 24MHz）
 *
 *  ESP32-P4 の LEDC は「全タイマがクロック源を共有する」ため、
 *  M5GFX がバックライト用に LEDC を使った後で設定すると
 *      E ledc: ledc_set_timer_div(693): timer clock conflict ...
 *  となり、24MHz が出せません。したがって【M5.begin() より先】に
 *  ここで設定しておく必要があります。
 *  ESP32-P4 の LEDC 出力周波数は「源クロック ÷ (分周比×2)」で、
 *  分周比は 1 以上なので、XTAL 40MHz では最大 20MHz（24MHz は出せない）。
 *  LEDC_AUTO_CLK に任せると PLL_F80M が選ばれ 24MHz が得られます。
 * ==========================================================================*/
static bool xclkTry(ledc_timer_t tm, ledc_channel_t ch, ledc_clk_cfg_t cc,
                    uint32_t want, const char* tag) {
  ledc_timer_config_t t = {};
  t.duty_resolution = LEDC_TIMER_1_BIT;
  t.freq_hz         = want;
  t.speed_mode      = LEDC_LOW_SPEED_MODE;
  t.clk_cfg         = cc;
  t.timer_num       = tm;
  if (ledc_timer_config(&t) != ESP_OK) return false;

  ledc_channel_config_t c = {};
  c.gpio_num   = PIN_XCLK;
  c.speed_mode = LEDC_LOW_SPEED_MODE;
  c.channel    = ch;
  c.intr_type  = LEDC_INTR_DISABLE;
  c.timer_sel  = tm;
  c.duty       = 1;
  c.hpoint     = 0;
  c.sleep_mode = LEDC_SLEEP_MODE_KEEP_ALIVE;
  if (ledc_channel_config(&c) != ESP_OK) return false;

  const uint32_t got = ledc_get_freq(LEDC_LOW_SPEED_MODE, tm);
  Serial.printf("[1] XCLK %s : %lu Hz\n", tag, (unsigned long)got);
  return (got >= 20000000UL && got <= 27000000UL);
}
static void xclkSetup(void) {
  if (xclkTry(LEDC_TIMER_3, LEDC_CHANNEL_7, LEDC_AUTO_CLK, 24000000UL, "24MHz AUTO t3")) return;
  if (xclkTry(LEDC_TIMER_2, LEDC_CHANNEL_6, LEDC_AUTO_CLK, 24000000UL, "24MHz AUTO t2")) return;
  if (xclkTry(LEDC_TIMER_3, LEDC_CHANNEL_7, LEDC_AUTO_CLK, 20000000UL, "20MHz AUTO t3")) return;
  Serial.println("[1] XCLK ★失敗 (20〜27MHz が出ませんでした)");
}

/* ==========================================================================
 *  【2】バックライトの救済
 *
 *  M5GFX はバックライトを LEDC で調光しようとしますが、上の XCLK で
 *  タイマを先に使っているため設定に失敗し、GPIO22 を LOW のまま終了して
 *  画面が真っ黒になることがあります（実機で確認）。
 *  そこで GPIO22 を“素の出力”として HIGH に固定します = 最大点灯。
 *  ※ このため setBrightness() による調光は効きません（常時最大）。
 * ==========================================================================*/
static void backlightFix(void) {
  pinMode(PIN_BL, OUTPUT);
  digitalWrite(PIN_BL, HIGH);
  delay(20);
  Serial.println("[2] バックライト GPIO22 を HIGH に固定（最大点灯）");
}

/* ==========================================================================
 *  【3】カメラ給電（IOエキスパンダ 0x43）
 *
 *  Tab5 の内蔵カメラは、IOエキスパンダ PI4IOE5V6408-1（I2C 0x43）の
 *  E1.P6 = CAM_RST で制御されています。これを解放しないとカメラは
 *  応答しません（SCCB スキャンで 0x36 が出てきません）。
 *
 *  ★重要: 0x01 レジスタ（チップリセット）に 0xFF を書いてはいけません。
 *    M5GFX が設定した LCD_RST / TP_RST まで巻き戻して画面が真っ黒に
 *    なります（切り分けテストで確定）。
 *
 *  レジスタの意味:
 *    0x01 CHIP_RESET / 0x03 IO_DIR / 0x05 OUT_SET / 0x07 OUT_HIM
 *    0x0B PULL_EN   / 0x0D PULL_SEL / 0x0F IN_STATE
 *  ビット割り当て（0x43）:
 *    bit1=SPK_EN  bit2=EXT5V_EN  bit4=LCD_RST  bit5=TP_RST  bit6=CAM_RST
 * ==========================================================================*/
static void expW(uint8_t reg, uint8_t val) {
  M5.In_I2C.writeRegister8(EXP_ADDR, reg, val, EXP_HZ);
}
static uint8_t expR(uint8_t reg) {
  return M5.In_I2C.readRegister8(EXP_ADDR, reg, EXP_HZ);
}
static void camPowerOn(void) {
  Serial.println("[3] カメラ給電（0x43 を設定 / 0x01=0xFF は行いません）");
  expW(0x0D, 0x7F);            /* PULL_SEL : 全ピン プル選択            */
  expW(0x0B, 0x7F);            /* PULL_EN  : 全ピン プル有効            */
  expW(0x05, 0x76);            /* OUT_SET  : SPK/EXT5V/LCD_RST/TP_RST/CAM_RST = 1 */
  expW(0x07, 0x00);            /* OUT_HIM  : 0 のとき OUT_SET が「High出力」 */
  expW(0x03, 0x7F); delay(10); /* IO_DIR : まず全出力に */
  expW(0x03, 0x6F); delay(50); /* IO_DIR : bit4(LCD_RST)だけ入力に戻す */
  Serial.printf("    完了 (0x03=%02X / 0x05=%02X / 0x07=%02X)\n",
                expR(0x03), expR(0x05), expR(0x07));
}

/* ==========================================================================
 *  明るさ関係の小道具
 * ==========================================================================*/
static inline uint32_t lum255(uint16_t v) {
  return (((v >> 11) & 0x1F) * 2 + ((v >> 5) & 0x3F) * 4 + (v & 0x1F)) * 255 / 345;
}

/* 画面全体の明暗の下端・上端を拾う（明暗伸長モード用。8画素に1回だけ見る） */
static void frameRange(const uint16_t* src, uint32_t w, uint32_t h,
                       uint32_t* lo, uint32_t* hi) {
  uint32_t a = 255, b = 0;
  const uint64_t n = (uint64_t)w * h;
  for (uint64_t i = 0; i < n; i += 8) {
    const uint32_t l = lum255(src[i]);
    if (l < a) a = l;
    if (l > b) b = l;
  }
  if (b <= a + 4) b = a + 4;
  *lo = a; *hi = b;
}

/* ==========================================================================
 *  【6】画面表示（1280x720）
 *
 *  カメラのフレームバッファ（PSRAM）から直接 DMA 転送すると黒画面に
 *  なることがあるため、16ラインずつ内蔵RAM（g_lbuf）へコピーしてから
 *  pushImage() で送ります。これが実機で安定しました。
 *
 *  ★ 色化け（赤と青が入れ替わる）は「バイト入替」で直ります。
 *     解析しない本スケッチでは、既定でバイト入替（mode=1）にしています。
 * ==========================================================================*/
static void displayFrame(const uint16_t* src, uint32_t w, uint32_t h,
                         int mode, uint32_t lo, uint32_t hi) {
  if (w != CAM_W || h != CAM_H) {
    Serial.printf("  ※映像サイズ %lux%lu は未対応です\n",
                  (unsigned long)w, (unsigned long)h);
    return;
  }
  M5.Display.startWrite();          /* 転送の開始/終了をまとめる（呼び出しの固定費を削る） */
  for (uint32_t y0 = 0; y0 < h; y0 += LCHUNK) {
    for (uint32_t r = 0; r < LCHUNK; r++) {
      const uint16_t* s = src + (uint64_t)(y0 + r) * w;
      uint16_t* d = g_lbuf + (uint64_t)r * w;
      if (mode == 1) {                       /* バイト入替 = 色化け対策 */
        for (uint32_t x = 0; x < w; x++) {
          const uint16_t v = s[x];
          d[x] = (uint16_t)((v >> 8) | (v << 8));
        }
      } else if (mode == 2) {                /* 明暗を伸ばして見やすく */
        const uint32_t span = hi - lo;
        for (uint32_t x = 0; x < w; x++) {
          uint32_t l = lum255(s[x]);
          l = (l > lo) ? (l - lo) : 0;
          l = l * 255 / span;
          if (l > 255) l = 255;
          d[x] = (uint16_t)(((l >> 3) << 11) | ((l >> 2) << 5) | (l >> 3));
        }
      } else {                               /* COLOR（生） */
        memcpy(d, s, (size_t)w * 2);
      }
    }
    M5.Display.pushImage(0, (int)y0, (int)w, LCHUNK, g_lbuf);
  }
  M5.Display.endWrite();
}

/* ---- 半分照明: 白い側を描く（静止画なので切り替え時だけ） ---- */
static void drawHalfLight(int lightX) {
  M5.Display.fillRect(lightX, 0, HALF_W, CAM_H, TFT_WHITE);
}

/* ---- 半分照明: 反対側にカメラ映像を出す --------------------------------
 * 1280x720 を縦横とも 1/2 に間引いて 640x360 にし、上下中央に置きます
 * （アスペクト比を保つため、上下に黒帯が残ります）。
 * g_lbuf は 1280x60 = 640x120 相当なので、そのまま使い回せます。      */
static void drawHalfPreview(const uint16_t* src, int px, int mode,
                            uint32_t lo, uint32_t hi) {
  const int y0 = (CAM_H - HALF_H) / 2;
  const int N  = 120;
  const uint32_t span = hi - lo;
  M5.Display.startWrite();
  for (int yb = 0; yb < HALF_H; yb += N) {
    const int rows = (HALF_H - yb < N) ? (HALF_H - yb) : N;
    for (int r = 0; r < rows; r++) {
      const uint16_t* s = src + (size_t)((yb + r) * 2) * CAM_W;
      uint16_t* d = g_lbuf + (size_t)r * HALF_W;
      if (mode == 1) {                       /* バイト入替（既定） */
        for (int x = 0; x < HALF_W; x++) {
          const uint16_t v = s[x << 1];
          d[x] = (uint16_t)((v >> 8) | (v << 8));
        }
      } else if (mode == 2) {                /* 明暗伸長 */
        for (int x = 0; x < HALF_W; x++) {
          uint32_t l = lum255(s[x << 1]);
          l = (l > lo) ? (l - lo) : 0;
          l = l * 255 / span;
          if (l > 255) l = 255;
          d[x] = (uint16_t)(((l >> 3) << 11) | ((l >> 2) << 5) | (l >> 3));
        }
      } else {                               /* 生 */
        for (int x = 0; x < HALF_W; x++) d[x] = s[x << 1];
      }
    }
    M5.Display.pushImage(px, y0 + yb, HALF_W, rows, g_lbuf);
  }
  M5.Display.endWrite();
}

/* ---- 半分照明: プレビュー側の下に状態を1行 ---- */
static void drawHalfBar(int px, int mode, float fps, uint32_t ms) {
  char line[64];
  snprintf(line, sizeof(line), "%s | %.1f fps | exp %ld",
           (mode == 1) ? "SWAP" : (mode == 2 ? "GRAY" : "RAW"),
           (double)fps, (long)EXP_TAB[g_expIdx]);
  M5.Display.fillRect(px, CAM_H - 26, HALF_W, 26, TFT_BLACK);
  M5.Display.setTextSize(1);
  M5.Display.setTextColor(TFT_GREEN, TFT_BLACK);
  M5.Display.setCursor(px + 6, CAM_H - 20);
  M5.Display.print(line);
  (void)ms;
}

/* 下のバー = 状態。1行で「いま何が出ているか」が分かるようにします。 */
static void drawOverlay(int mode, float fps, uint32_t dispMs) {
  const uint32_t sw = M5.Display.width(), sh = M5.Display.height();
  char line[96];
  snprintf(line, sizeof(line), "%s | %4.1f fps | %2lu ms | exp %ld | gain %ld%s",
           MNAME[mode], (double)fps, (unsigned long)dispMs,
           (long)EXP_TAB[g_expIdx], (long)GAIN_TAB[g_gainIdx],
           g_manual ? "*" : "");
  M5.Display.fillRect(0, (int)sh - 30, (int)sw, 30, TFT_BLACK);
  M5.Display.setTextSize(2);
  M5.Display.setTextColor(TFT_GREEN, TFT_BLACK);
  M5.Display.setCursor(6, (int)sh - 24);
  M5.Display.print(line);
}

/* ==========================================================================
 *  【7】ライトモード: 画面をライト代わりにする
 *
 *  画面を真っ白にすると、5インチ・バックライト最大点灯の光が
 *  「照明」として使えます。暗い場所で読むときの工夫です。
 *  ・ライトモード中もカメラは動き続けます（止めると再開に時間がかかる）。
 *  ・バックライトは GPIO22 直Highで常時最大点灯なので、白画面が最大光量。
 *  ※ 画面と対象の向き・距離で効果は変わります。実機でお試しください。
 * ==========================================================================*/
static void lightRedraw(void) {
  M5.Display.fillScreen(TFT_WHITE);
  M5.Display.setTextSize(2);
  M5.Display.setTextColor(TFT_BLACK, TFT_WHITE);
  M5.Display.setCursor(10, 10);
  M5.Display.print("LIGHT MODE  (press 'l' to exit)");
  M5.Display.setCursor(10, 40);
  M5.Display.print("screen light is ON / camera keeps running");
}

/* ==========================================================================
 *  【8】露出・ゲイン（実験的）
 *
 *  ESP_Video の capture デバイスには V4L2 の拡張コントロールが用意されて
 *  います（ESP_Video.h）:
 *      setSensorExposureTime(int32_t) / setSensorGain(int32_t)
 *      setSensorExposure(int32_t)     / setSensorAETargetLevel(int32_t)
 *  ここでは露出時間とゲインだけを触ります。
 *  返り値は「ioctl が通ったか」であって「映像が変わったか」ではありません。
 *  押しても明るさが変わらない場合は、ドライバが AE を優先しています。
 * ==========================================================================*/
static void applyExposure(void) {
  const bool okE = cap.setSensorExposureTime(EXP_TAB[g_expIdx]);
  const bool okG = cap.setSensorGain(GAIN_TAB[g_gainIdx]);
  Serial.printf("[8] 露出 %ld -> %s / ゲイン %ld -> %s\n",
                (long)EXP_TAB[g_expIdx], okE ? "OK" : "NG",
                (long)GAIN_TAB[g_gainIdx], okG ? "OK" : "NG");
  if (!okE && !okG) Serial.println("    （ドライバが AE 優先の可能性があります）");
}

/* ==========================================================================
 *  setup: 【1】〜【4】と初期化
 * ==========================================================================*/
void setup() {
  Serial.begin(115200);
  delay(600);
  Serial.println();
  Serial.println("### Tab5 内蔵カメラ サンプル（カメラ単体版）###");

  /* ISP のログ洪水（ISP: failed to set CCM）を抑える。
   * これは表示には影響しない化粧的なログですが、シリアルが見にくくなるため。 */
  esp_log_level_set("ISP",       ESP_LOG_NONE);
  esp_log_level_set("ISP_CCM",   ESP_LOG_NONE);
  esp_log_level_set("isp_video", ESP_LOG_NONE);
  esp_log_level_set("esp_video", ESP_LOG_NONE);

  /* 【1】XCLK は M5.begin() より先 */
  xclkSetup();

  /* 【2】M5Unified 開始 */
  Serial.println("[2] M5Unified 開始");
  auto cfg = M5.config();
  M5.begin(cfg);
  M5.Display.setRotation(1);          /* Tab5 は既定が縦。横 1280x720 にする */
  M5.Display.setBrightness(255);      /* バックライト最大（実際は backlightFix が効く） */
  Serial.printf("    画面 %dx%d rotation=%d\n",
                (int)M5.Display.width(), (int)M5.Display.height(),
                (int)M5.Display.getRotation());
  backlightFix();

  /* 画面が出ているかの確認（カラーバー） */
  M5.Display.fillScreen(TFT_WHITE); delay(300);
  M5.Display.fillScreen(TFT_RED);   delay(300);
  M5.Display.fillScreen(TFT_GREEN); delay(300);
  M5.Display.fillScreen(TFT_BLUE);  delay(300);
  M5.Display.fillScreen(TFT_BLACK);
  Serial.println("    カラーバー完了（真っ黒なら backlightFix を確認）");

  /* 【3】カメラ給電 */
  camPowerOn();

  /* 【4】ESP_Video 初期化
   *  ─ SCCB（I2C）は Tab5 のカメラ専用: SCL=G32 / SDA=G31。
   *    M5.begin() が内部I2Cでポート1を使っているので、こちらは ポート0 を使う。
   *    （M5Unified の版によっては、内蔵I2Cのポートを release() して
   *      貸し借りする方式が必要になります。README の「罠」を参照）
   *  ─ dont_init_ldo = true が必須。
   *    ESP32-P4 の MIPI PHY 用 LDO（チャネル3 / 2500mV）は、画面（DSI）と
   *    カメラ（CSI）で共用です。M5GFX が既に取得しているので、ESP_Video 側で
   *    取り直すと失敗します。
   *  ─ 「Failed to detect camera sensor with address=..」は
   *    ドライバが候補アドレスを順に試すときのログで、正常です（無害）。 */
  Serial.println("[4] ESP_Video 初期化（dont_init_ldo=true）");
  const i2c_port_num_t m5p = (i2c_port_num_t)M5.In_I2C.getPort();
  const i2c_port_num_t cp  = (m5p == I2C_NUM_0) ? I2C_NUM_1 : I2C_NUM_0;
  ESPVideoCamConfigClass cam_config;
  cam_config.begin(cp, 32, 31, 400000);     /* port, SCL, SDA, 400kHz */
  ESPVideoCSIConfigClass csi;
  csi.begin(cam_config, true);              /* true = dont_init_ldo */

  if (!video.begin(csi))                             { Serial.println("  ★video.begin 失敗"); return; }
  M5.Display.fillScreen(TFT_CYAN); delay(300);
  if (!cap.begin(ESP_VIDEO_MIPI_CSI_DEVICE_NAME, 3)) { Serial.println("  ★cap.begin 失敗");    return; }
  if (!cap.setFormat(ESP_VIDEO_FORMAT_RGB565))       { Serial.println("  ★setFormat 失敗");   return; }
  if (!cap.startCapture())                           { Serial.println("  ★startCapture 失敗"); return; }
  Serial.println("    初期化成功 / 撮影開始（RGB565 1280x720）");

  M5.Display.fillScreen(TFT_GREEN);
  M5.Display.setTextSize(3);
  M5.Display.setTextColor(TFT_BLACK, TFT_GREEN);
  M5.Display.setCursor(24, 24);
  M5.Display.print("CAM INIT OK");
  delay(1000);
  M5.Display.fillScreen(TFT_BLACK);

  Serial.println("=== 開始 ===");
  Serial.println(" キー: c=COLOR s=バイト入替 g=GRAY t=半分照明のON/OFF l=全面ライト r=白い側の左右 e/E=露出 -/+ =ゲイン 0=初期値 h=ヘルプ");
  Serial.println(" 起動時は「左半分が白（照明）／右半分が映像」です。タッチは読みません。");
}

/* ==========================================================================
 *  loop: 【5】撮影 → 【6】表示（＋おまけで【8】）
 * ==========================================================================*/
void loop() {
  const uint32_t sw = M5.Display.width(), sh = M5.Display.height();

  static int      mode     = 1;      /* 既定はバイト入替（色化けが直る） */
  static int      lastMode = -1;

  /* ---- シリアルからのキー入力 ---- */
  while (Serial.available()) {
    const int k = Serial.read();
    if      (k == 'c' || k == 'C') { mode = 0; Serial.println("  → COLOR（生）"); }
    else if (k == 's' || k == 'S') { mode = 1; Serial.println("  → COLOR バイト入替（色化け対策）"); }
    else if (k == 'g' || k == 'G') { mode = 2; Serial.println("  → GRAY（明暗を伸ばす）"); }
    else if (k == 'l' || k == 'L') {
      g_light = g_light ? 0 : 1;
      g_lightRedraw = true;
      Serial.printf("  → ライトモード %s\n", g_light ? "ON（画面を白くして照明）" : "OFF");
    }
    else if (k == 't' || k == 'T') {
      g_half = g_half ? 0 : 1;
      g_halfRedraw = true;
      Serial.printf("  → 半分照明 %s\n",
                    g_half ? "ON（左半分が白／右半分が映像）" : "OFF（全面に映像）");
    }
    else if (k == 'r' || k == 'R') {
      g_lightLeft = g_lightLeft ? 0 : 1;
      g_halfRedraw = true; g_lightRedraw = true;
      Serial.printf("  → 白い側を%s半分に変更\n", g_lightLeft ? "左" : "右");
    }
    else if (k == 'e') { if (g_expIdx > 0)      g_expIdx--;        g_manual = true; applyExposure(); }
    else if (k == 'E') { if (g_expIdx < EXP_N-1) g_expIdx++;       g_manual = true; applyExposure(); }
    else if (k == '-') { if (g_gainIdx > 0)      g_gainIdx--;      g_manual = true; applyExposure(); }
    else if (k == '+' || k == '=') { if (g_gainIdx < GAIN_N-1) g_gainIdx++; g_manual = true; applyExposure(); }
    else if (k == '0') { g_expIdx = 3; g_gainIdx = 0; g_manual = false; applyExposure(); }
    else if (k == 'h' || k == 'H') {
      Serial.println("  c=COLOR / s=バイト入替 / g=GRAY / t=半分照明 / l=全面ライト / r=白い側の左右 / e,E=露出 / -,+ =ゲイン / 0=初期値 / h=ヘルプ");
    }
  }

  /* ---- 【5】フレーム取得 ----
   *  ESPVideoBufferClass は「その場限りの借用証」です。変数が消えると
   *  フレームがドライバに返ります（デストラクタで自動的に返却）。
   *  つまり、この変数を長く持つほど次のフレームが来にくくなります。
   *  重い処理をするときは、先に必要な情報だけコピーして手放します。 */
  const uint32_t t0 = millis();
  ESPVideoBufferClass buf = cap.captureBuffer();
  if (!buf.valid()) { delay(5); return; }
  const uint32_t w = buf.getWidth(), h = buf.getHeight();
  const uint16_t* src = (const uint16_t*)buf.data();

  /* ---- 【6】表示 ---- */
  const uint32_t t1 = millis();
  if (g_light) {
    if (g_lightRedraw) { g_lightRedraw = false; lightRedraw(); }
  } else if (g_half) {
    const int lightX = g_lightLeft ? 0 : HALF_W;
    const int px     = g_lightLeft ? HALF_W : 0;
    if (g_halfRedraw) {
      g_halfRedraw = false;
      M5.Display.fillScreen(TFT_BLACK);
      drawHalfLight(lightX);
    }
    uint32_t lo = 0, hi = 255;
    if (mode == 2) frameRange(src, w, h, &lo, &hi);
    drawHalfPreview(src, px, mode, lo, hi);
  } else {
    uint32_t lo = 0, hi = 255;
    if (mode == 2) frameRange(src, w, h, &lo, &hi);
    displayFrame(src, w, h, mode, lo, hi);
  }

  /* ---- おまけ: fps と表示時間（状態バー用） ---- */
  static uint32_t frames = 0, lastSec = 0, dispTotal = 0;
  static float    fps = 0.0f;
  const uint32_t dispMs = millis() - t1;
  frames++; dispTotal += dispMs;
  if (millis() - lastSec >= 1000) {
    fps = frames * 1000.0f / (float)(millis() - lastSec);
    if (g_light) { /* 全面照明: バーは出さない */ }
    else if (g_half) {
      drawHalfBar(g_lightLeft ? HALF_W : 0, mode, fps, dispTotal / frames);
    } else {
      drawOverlay(mode, fps, dispTotal / frames);
    }
    frames = 0; dispTotal = 0; lastSec = millis();
  }
  /* buf はここで自動的に返却されます */

  /* ---- 5秒ごとの様子 ---- */
  static uint32_t last = 0;
  if (millis() - last >= 5000) {
    last = millis();
    Serial.printf("  [%lux%lu] mode=%s / %.1f fps / 露出 %ld / ゲイン %ld%s\n",
                  (unsigned long)w, (unsigned long)h, MNAME[mode], (double)fps,
                  (long)EXP_TAB[g_expIdx], (long)GAIN_TAB[g_gainIdx],
                  g_light ? " / ライトモードON" : "");
  }
  (void)sh; (void)lastMode;
}
