#!/usr/bin/env bash
# av1-slim.sh — 用 AV1(SVT-AV1) 对「迅雷下载」里的视频批量瘦身
#
# 设计要点
#   · 只处理指定目录（默认 /home/zyw/Downloads/xunlei-downloads），按体积从大到小
#   · 转码写入同目录下的隐藏临时文件（列表页会隐藏点开头文件），完成校验后原子替换
#   · 原文件移到隐藏备份目录（同一文件系统，mv 瞬时），保留 N 天可一键回滚
#   · 转换前查编码/码率，已是 AV1 或本来就压得很紧的直接跳过
#   · 转换后校验：时长一致 + 抽样 SSIM 达标 + 体积确有下降，任一不过就保留原文件
#   · 可限时（--until / --max-hours），nice 降优先级，线程数可控，适合夜间跑
#
# 本机实测（60 秒 1080p 素材，crf 33，4 线程 + nice 19）
#   preset  8 : 213s  0.28x实时  省 75%  抽样SSIM 0.9916
#   preset 10 : 124s  0.48x实时  省 75%  抽样SSIM 0.9910
#   preset 12 :  66s  0.91x实时  省 74%  抽样SSIM 0.9901
#
# ⚠ 关键：SVT-AV1 默认开启时域滤波（enable-tf=1），在运动画面上会产生块状伪影和拖影，
#   社区实测一致结论是关掉（脚本已内置 enable-tf=0）。关掉后 SVT-AV1 仍是画质上限；
#   而 RTX 50 系的 av1_nvenc 默认 tune=hq（自带关 TF）速度是 CPU 的十倍以上、质量接近。
#   选型实测与建议见 dl-hub/05-下载服务维护/2026-09-19-50系显卡编码选择-实测与建议.md
#
# 质量经验（2026-09-19 按用户反馈修正）：crf 33 + preset 12 在低码率源（720p/2Mbps 这类）上
#   会出现肉眼可见的模糊与运动拖影 —— 当时输出仅 0.56Mbps，SSIM 0.9817 虽过线但观感差。
#   现默认改为 crf 28 + preset 10，并把 SSIM 门槛提到 0.99。低码率源建议先转 60 秒样本对比。
#
# 常用
#   bash ~/Downloads/av1-slim.sh --dry-run                  # 只看要处理哪些、省多少
#   bash ~/Downloads/av1-slim.sh --until 07:00              # 跑到早上 7 点停
#   bash ~/Downloads/av1-slim.sh --limit 2 --max-hours 3    # 先试两个
#   bash ~/Downloads/av1-slim.sh --status                   # 看进度统计
#   bash ~/Downloads/av1-slim.sh --list-backups             # 看可回滚的备份
#   bash ~/Downloads/av1-slim.sh --rollback-all             # 全部回滚
#   bash ~/Downloads/av1-slim.sh --purge-backups 7          # 清理 7 天前的备份
#
# 画质优先（不考虑速度）—— 只关心「画面最好 + 体积最小」时用这个
#   bash ~/Downloads/av1-slim.sh --archive --threads 16 --dry-run   # 先看清单
#   bash ~/Downloads/av1-slim.sh --archive --threads 16             # 正式跑（很慢）
#   等价于：-preset 4 -crf 21 -svtav1-params "enable-tf=0:tune=0" -pix_fmt yuv420p10le
#   可单独覆盖慢档：--archive --preset 2 --crf 19 --ssim-min 0.996
#   胶片颗粒重的老片可加：--svt-params "enable-tf=0:tune=0:film-grain=8:film-grain-denoise=1"
set -uo pipefail

# ── 默认参数 ──────────────────────────────────────────────────────────────
DIR="/home/zyw/Downloads/xunlei-downloads"
MIN_SIZE_MB=300          # 小于此体积不处理
CRF=28                   # AV1 质量：数值越小越清晰。crf33 在低码率源上有模糊/拖影，默认改 28
PRESET=10                # SVT-AV1 preset：数字越大越快。10 比 12 慢约 2 倍但质量明显更好
THREADS=4                # 编码线程上限（本机 8 线程，避免占满）
MAX_HOURS=0              # 本次最多跑多少小时（0=不限）
UNTIL=""                 # 到该时刻停止（HH:MM）
LIMIT=0                  # 本次最多处理几个文件（0=不限）
MIN_SAVE=15              # 预计省不到该百分比就跳过
SSIM_MIN=0.990           # 抽样 SSIM 阈值（提高门槛，避免“能过但看着糊”）
SSIM_SAMPLES=3           # 抽样段数
SSIM_SECS=20             # 每段秒数
AUDIO_BITRATE=128k
FREE_RESERVE_GB=3        # 每个文件开始前要求的最小剩余空间
SVT_EXTRA="enable-tf=0"  # 额外透传给 SVT-AV1 的参数（:-分隔，会与 lp= 拼在一起）
PIXFMT_OUT=""            # 输出像素格式（空=按源自动；archive 模式用 yuv420p10le）
ARCHIVE=0                # --archive 画质优先模式
EXPLICIT_CRF=0; EXPLICIT_PRESET=0
DRY_RUN=0; ASSUME_YES=0
MODE="run"

HOME_DIR="$HOME"
STATE_DIR="$HOME/.av1-slim"
STATE="$STATE_DIR/state.tsv"
BACKUP_IDX="$STATE_DIR/backup.tsv"
LOCK="$STATE_DIR/lock"
LOG="$STATE_DIR/run.log"
BACKUP_DIR="/home/zyw/Downloads/.av1-slim-backup"
REPORT_MD="/home/zyw/Downloads/dl-hub/05-下载服务维护/AV1瘦身记录.md"

while [ $# -gt 0 ]; do
  case "$1" in
    --dir) DIR="$2"; shift 2;;
    --min-size) MIN_SIZE_MB="$2"; shift 2;;
    --crf) CRF="$2"; EXPLICIT_CRF=1; shift 2;;
    --preset) PRESET="$2"; EXPLICIT_PRESET=1; shift 2;;
    --archive|--quality) ARCHIVE=1; shift;;
    --svt-params) SVT_EXTRA="$2"; shift 2;;
    --pixfmt) PIXFMT_OUT="$2"; shift 2;;
    --threads) THREADS="$2"; shift 2;;
    --max-hours) MAX_HOURS="$2"; shift 2;;
    --until) UNTIL="$2"; shift 2;;
    --limit) LIMIT="$2"; shift 2;;
    --min-save) MIN_SAVE="$2"; shift 2;;
    --ssim-min) SSIM_MIN="$2"; shift 2;;
    --ssim-samples) SSIM_SAMPLES="$2"; shift 2;;
    --ssim-secs) SSIM_SECS="$2"; shift 2;;
    --audio-bitrate) AUDIO_BITRATE="$2"; shift 2;;
    --free-reserve) FREE_RESERVE_GB="$2"; shift 2;;
    --dry-run) DRY_RUN=1; shift;;
    --yes|-y) ASSUME_YES=1; shift;;
    --status) MODE="status"; shift;;
    --list-backups) MODE="list-backups"; shift;;
    --rollback) MODE="rollback"; RB_TARGET="$2"; shift 2;;
    --rollback-all) MODE="rollback-all"; shift;;
    --purge-backups) MODE="purge"; PURGE_DAYS="${2:-7}"; shift 2;;
    --report) MODE="report"; shift;;
    -h|--help) sed -n '2,40p' "$0"; exit 0;;
    *) echo "未知参数: $1（--help 看用法）"; exit 2;;
  esac
done

# --archive：只考虑画质与体积、不考虑速度（preset 4 + crf 21 + VQ 视觉调优 + 10bit 输出）
if [ "$ARCHIVE" = 1 ]; then
  [ "$EXPLICIT_PRESET" = 0 ] && PRESET=4
  [ "$EXPLICIT_CRF" = 0 ] && CRF=21
  SVT_EXTRA="enable-tf=0:tune=0"
  [ -z "$PIXFMT_OUT" ] && PIXFMT_OUT="yuv420p10le"
  [ "$SSIM_MIN" = "0.990" ] && SSIM_MIN=0.995
  echo "📼 archive 画质优先模式：preset=$PRESET crf=$CRF tune=VQ 输出=$PIXFMT_OUT（很慢，忽略速度）"
fi

mkdir -p "$STATE_DIR" "$BACKUP_DIR"
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG"; }
die() { echo "❌ $*" >&2; exit 1; }
human() { awk -v s="$1" 'BEGIN{split("B KB MB GB TB",u," ");i=1;while(s>=1024&&i<5){s/=1024;i++}printf "%.2f %s",s,u[i]}'; }
need() { command -v "$1" >/dev/null || die "缺少命令：$1"; }
need ffmpeg; need ffprobe
[ -d "$DIR" ] || die "目录不存在：$DIR"

# ── 状态读写 ──────────────────────────────────────────────────────────────
state_get() { [ -f "$STATE" ] && awk -F'\t' -v p="$1" '$2==p{print; exit}' "$STATE"; }
state_put() { # status path orig_size new_size ssim dur [newpath]
  local st="$1" p="$2" o="$3" n="$4" s="$5" d="$6" np="${7:-}"
  local tmp="$STATE.tmp"
  [ -f "$STATE" ] && awk -F'\t' -v p="$p" '$2!=p' "$STATE" > "$tmp" || : > "$tmp"
  printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$st" "$p" "$o" "$n" "$s" "$d" "$(date '+%F %T')" "$np" >> "$tmp"
  mv "$tmp" "$STATE"
}

# ── 探测 ──────────────────────────────────────────────────────────────────
probe_json() { ffprobe -v error -show_entries format=duration,bit_rate -show_entries stream=codec_type,codec_name,width,height,avg_frame_rate,pix_fmt -of json "$1" 2>/dev/null; }
jget() { printf '%s' "$1" | node -e '
let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j={};try{j=JSON.parse(s)}catch(e){}
const st=j.streams||[];const v=st.find(x=>x.codec_type==="video")||{};const a=st.find(x=>x.codec_type==="audio")||{};
const f=j.format||{};console.log([v.codec_name||"",v.width||0,v.height||0,v.pix_fmt||"",v.avg_frame_rate||"",a.codec_name||"",f.duration||0,f.bit_rate||0].join("\t"))})'; }

# 抽样 SSIM（在 N 个位置各取 SSIM_SECS 秒比对）
sample_ssim() {
  local src="$1" out="$2" dur="$3"
  local n="$SSIM_SAMPLES" ok=0 sum=0 i=1
  while [ "$i" -le "$n" ]; do
    local off
    off=$(awk -v d="$dur" -v i="$i" -v n="$n" 'BEGIN{o=d*i/(n+1)-10; if(o<0)o=0; printf "%.2f", o}')
    local v
    v=$(ffmpeg -hide_banner -nostdin -ss "$off" -t "$SSIM_SECS" -i "$out" -ss "$off" -t "$SSIM_SECS" -i "$src" \
         -lavfi ssim -f null - 2>&1 | grep -o 'All:[0-9.]*' | head -1 | cut -d: -f2)
    if [ -n "$v" ]; then sum=$(awk -v a="$sum" -v b="$v" 'BEGIN{print a+b}'); ok=$((ok+1)); fi
    i=$((i+1))
  done
  [ "$ok" -eq 0 ] && { echo ""; return; }
  awk -v s="$sum" -v k="$ok" 'BEGIN{printf "%.5f", s/k}'
}

# ── 子命令：状态 / 备份 / 回滚 ────────────────────────────────────────────
if [ "$MODE" = "status" ]; then
  [ -f "$STATE" ] || { echo "还没有任何处理记录（$STATE 不存在）"; exit 0; }
  awk -F'\t' '{c[$1]++; if($1=="done"){os+=$3; ns+=$4}} END{
    printf "  已完成 %d 个：%.1f GB → %.1f GB（省 %.1f GB）\n", c["done"], os/1073741824, ns/1073741824, (os-ns)/1073741824;
    printf "  跳过 %d 个 · 失败 %d 个 · 回滚 %d 个\n", c["skip"], c["fail"], c["rolledback"]}' "$STATE"
  echo "  最近 10 条："
  tail -10 "$STATE" | awk -F'\t' '{printf "    [%s] %s  %s→%s  SSIM=%s\n", $1, $7, $3, $4, $5}'
  echo "  备份占用：$(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1)  剩余空间：$(df -h /home | tail -1 | awk '{print $4}')"
  exit 0
fi

if [ "$MODE" = "list-backups" ]; then
  [ -f "$BACKUP_IDX" ] || { echo "没有备份"; exit 0; }
  echo "  备份（可回滚）："
  awk -F'\t' '{printf "    %s  %s  (%s)\n", $4, $1, $3}' "$BACKUP_IDX"
  echo "  合计 $(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1) → $BACKUP_DIR"
  exit 0
fi

if [ "$MODE" = "rollback" ] || [ "$MODE" = "rollback-all" ]; then
  [ -f "$BACKUP_IDX" ] || die "没有备份索引"
  n=0
  while IFS=$'\t' read -r rel bak orig_size ts cur; do
    [ -z "${rel:-}" ] && continue
    # 兼容只有 4 列的旧索引
    if [ -z "${cur:-}" ]; then cur="$rel"; fi
    if [ "$MODE" = "rollback" ] && [ "$rel" != "$RB_TARGET" ]; then continue; fi
    [ -f "$bak" ] || { echo "  ⚠ 备份文件缺失：$bak"; continue; }
    # 若转换时改过名（如 .avi → .mp4），先删掉新文件再恢复原名
    [ "$cur" != "$rel" ] && [ -f "$cur" ] && rm -f "$cur"
    mv -f "$bak" "$rel" && { state_put "rolledback" "$rel" "$orig_size" "-" "-" "-"; n=$((n+1)); echo "  ↩ 已回滚 $rel"; }
  done < "$BACKUP_IDX"
  # 重建索引（去掉已回滚的）
  if [ "$MODE" = "rollback-all" ]; then : > "$BACKUP_IDX"; else
    awk -F'\t' -v t="$RB_TARGET" '$1!=t' "$BACKUP_IDX" > "$BACKUP_IDX.tmp" && mv "$BACKUP_IDX.tmp" "$BACKUP_IDX"; fi
  echo "  共回滚 $n 个文件"; exit 0
fi

if [ "$MODE" = "purge" ]; then
  days="${PURGE_DAYS:-7}"
  echo "  清理 $days 天前的备份（$BACKUP_DIR）…"
  n=0
  while IFS=$'\t' read -r rel bak orig_size ts; do
    [ -z "${rel:-}" ] && continue
    if [ -f "$bak" ] && [ -n "$(find "$bak" -mtime "+$days" -print -quit 2>/dev/null)" ]; then
      rm -f "$bak"; echo "    🗑 删除 $(basename "$bak")"; n=$((n+1))
    fi
  done < "$BACKUP_IDX" 2>/dev/null
  # 索引里剔除已不存在的备份
  if [ -f "$BACKUP_IDX" ]; then
    awk -F'\t' '{ if (system("test -f \"" $2 "\"")==0) print }' "$BACKUP_IDX" > "$BACKUP_IDX.tmp" 2>/dev/null
    mv -f "$BACKUP_IDX.tmp" "$BACKUP_IDX"
  fi
  echo "  删除 $n 个；剩余备份：$(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1)"; exit 0
fi

# ── 生成候选清单（全程 NUL 分隔，兼容空格/中文/换行文件名；按体积从大到小）──
mapfile -d '' -t CANDIDATES < <(
  find "$DIR" -type f \
    \( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.avi' -o -iname '*.mov' -o -iname '*.wmv' -o -iname '*.ts' -o -iname '*.m2ts' -o -iname '*.flv' -o -iname '*.rmvb' -o -iname '*.webm' \) \
    -size +"${MIN_SIZE_MB}"M -printf '%s\t%p\0' 2>/dev/null | sort -z -rn | cut -z -f2-
)

[ "${#CANDIDATES[@]}" -eq 0 ] && { echo "没有符合条件的文件（>$MIN_SIZE_MB MB）"; exit 0; }

TOTAL_BYTES=0
for f in "${CANDIDATES[@]}"; do
  sz=$(stat -c %s "$f" 2>/dev/null || echo 0); TOTAL_BYTES=$((TOTAL_BYTES + sz))
done
# 粗略预估省的比例（经验值，随 CRF 上升而变大；实际取决于源码率，仅作参考）
est_pct=50
if   [ "$CRF" -le 20 ]; then est_pct=35
elif [ "$CRF" -le 22 ]; then est_pct=42
elif [ "$CRF" -le 26 ]; then est_pct=52
elif [ "$CRF" -le 30 ]; then est_pct=60
else est_pct=70; fi
EST_TOTAL=$(( TOTAL_BYTES * (100 - est_pct) / 100 ))

echo "═══ AV1 瘦身计划 ═══"
echo "  目录：$DIR"
echo "  候选：${#CANDIDATES[@]} 个文件，合计 $(human "$TOTAL_BYTES")"
echo "  预估产出约 $(human "$EST_TOTAL")（经验值约省 ${est_pct}%；实际取决于源码率，已经压得紧的省得少）"
echo "  参数：SVT-AV1 preset=$PRESET crf=$CRF threads=$THREADS  校验 SSIM≥$SSIM_MIN（抽 $SSIM_SAMPLES 段）"
if [ "$ARCHIVE" = 1 ]; then
  echo "  ⚠ 画质优先：目标是「画质不降的前提下体积最小」，不为了变小牺牲画面；"
  echo "     已经压得很紧的低码率源可能省不到 ${MIN_SAVE}%，这类会被自动跳过。"
fi
[ "$DRY_RUN" = 1 ] && echo "  ⚠ 试运行模式：只列清单，不做任何改动"
echo "  清单（前 15）："
for f in "${CANDIDATES[@]:0:15}"; do
  printf "    %10s  %s\n" "$(human "$(stat -c %s "$f")")" "${f#"$DIR"/}"
done
[ "$DRY_RUN" = 1 ] && exit 0

# ── 并发保护 ──────────────────────────────────────────────────────────────
exec 9>"$LOCK" || die "无法创建锁文件"
flock -n 9 || die "已有另一个 av1-slim 在运行（$LOCK）"

if [ "$ASSUME_YES" != 1 ]; then
  read -r -p "确认开始？(y/N) " a; [ "$a" = "y" ] || [ "$a" = "Y" ] || { echo "已取消"; exit 0; }
fi

DEADLINE=0
[ "$MAX_HOURS" != 0 ] && DEADLINE=$(( $(date +%s) + MAX_HOURS * 3600 ))
[ -n "$UNTIL" ] && {
  t=$(date -d "$UNTIL" +%s 2>/dev/null) || die "--until 格式应为 HH:MM"
  now=$(date +%s); [ "$t" -le "$now" ] && t=$((t + 86400))   # 已过点则指明天
  [ "$DEADLINE" -eq 0 ] || [ "$t" -lt "$DEADLINE" ] && DEADLINE="$t"
}
deadline_hit() { [ "$DEADLINE" -gt 0 ] && [ "$(date +%s)" -ge "$DEADLINE" ]; }

# ── 主循环 ────────────────────────────────────────────────────────────────
DONE=0; SKIP=0; FAIL=0; SAVED=0; PROCESSED=0
CUR_PID=""; CUR_TMP=""
on_int() {
  echo
  log "[中断] 收到信号，正在停止当前转码并清理临时文件…"
  [ -n "$CUR_PID" ] && kill -TERM "$CUR_PID" 2>/dev/null
  sleep 1
  [ -n "$CUR_TMP" ] && rm -f "$CUR_TMP"
  log "[中断] 已停止；原文件未改动，下次运行会自动跳过已完成、继续剩余文件"
  exit 130
}
trap on_int INT TERM

for f in "${CANDIDATES[@]}"; do
  [ "$LIMIT" -gt 0 ] && [ "$PROCESSED" -ge "$LIMIT" ] && { log "已达 --limit $LIMIT，停止"; break; }
  deadline_hit && { log "已到限定时间，停止（下次运行会从剩余文件继续）"; break; }

  sz=$(stat -c %s "$f")
  rel="$f"
  # 已处理过且文件没变（体积一致）→ 跳过
  prev=$(state_get "$rel")
  if [ -n "$prev" ]; then
    pst=$(printf '%s' "$prev" | cut -f1); psz=$(printf '%s' "$prev" | cut -f3)
    if [ "$pst" = "done" ] || [ "$pst" = "skip" ]; then [ "$psz" = "$sz" ] && continue; fi
  fi

  PROCESSED=$((PROCESSED + 1))
  echo
  log "▶ [$PROCESSED] $(basename "$f")  $(human "$sz")"

  info=$(probe_json "$f"); IFS=$'\t' read -r vcodec w h pixf fps acodec dur bitrate <<<"$(jget "$info")"
  [ -z "${vcodec:-}" ] && { log "  ⏭ 无法解析（可能不是视频），跳过"; state_put "skip" "$rel" "$sz" "-" "-" "-"; SKIP=$((SKIP+1)); continue; }
  case "$vcodec" in av1) log "  ⏭ 已经是 AV1，跳过"; state_put "skip" "$rel" "$sz" "-" "-" "-"; SKIP=$((SKIP+1)); continue;; esac

  # 码率/像素 判据：压得很紧的（bpp<0.045）不划算
  bpp=$(awk -v br="${bitrate:-0}" -v w="${w:-0}" -v h="${h:-0}" -v fps="${fps:-0}" 'BEGIN{
    split(fps,a,"/"); f=(a[2]?a[1]/a[2]:a[1]); if(br<=0||w<=0||h<=0||f<=0){print 0; exit} printf "%.4f", br/(w*h*f)}')
  # 低码率源提醒：本身压得很紧时再大幅降码率极易出现模糊/拖影
  if awk -v b="$bpp" 'BEGIN{exit !(b>0 && b<0.085)}'; then
    log "  ℹ 源码率偏低（bpp=$bpp ≈ $(awk -v br="${bitrate:-0}" 'BEGIN{printf "%.2f", br/1000000}') Mbps）→ 建议用更小 crf（如 --crf 24）避免模糊/拖影"
  fi
  if awk -v b="$bpp" 'BEGIN{exit !(b>0 && b<0.045)}'; then
    log "  ⏭ 码率已经很紧（bpp=$bpp），预计省不了多少，跳过"
    state_put "skip" "$rel" "$sz" "-" "-" "${dur:-0}"; SKIP=$((SKIP+1)); continue
  fi

  # 预计产出与空间检查
  est=$((sz / 2))
  free_b=$(( $(df -Pk /home | awk 'NR==2{print $4}') * 1024 ))
  if [ "$free_b" -lt $((est + FREE_RESERVE_GB * 1073741824)) ]; then
    log "  ⏭ 剩余空间不足（可用 $(human "$free_b")，需 $(human $((est + FREE_RESERVE_GB*1073741824)))），跳过"
    state_put "fail" "$rel" "$sz" "-" "-" "${dur:-0}"; FAIL=$((FAIL+1)); continue
  fi

  d="$(dirname "$f")"; b="$(basename "$f")"
  tmp="$d/.av1tmp-$b.mp4"
  pixfmt_out="yuv420p"; case "$pixf" in *10le|*p10*) pixfmt_out="yuv420p10le";; esac
  [ -n "$PIXFMT_OUT" ] && pixfmt_out="$PIXFMT_OUT"
  case "${acodec:-}" in aac|mp3|opus|"") acopy=1;; *) acopy=0;; esac

  log "  编码中：$vcodec→av1 ${w}x${h} ${pixf}→$pixfmt_out 音频$([ $acopy = 1 ] && echo "直拷($acodec)" || echo "转AAC($acodec)")"
  T0=$(date +%s)
  aargs=(-map 0:v:0); if [ $acopy = 1 ]; then aargs+=(-map "0:a:0?" -c:a copy); else aargs+=(-map "0:a:0?" -c:a aac -b:a "$AUDIO_BITRATE"); fi
  ERRLOG="$STATE_DIR/ffmpeg.err"
  nice -n 19 ffmpeg -hide_banner -nostdin -loglevel error -y -i "$f" \
    "${aargs[@]}" -sn -c:v libsvtav1 -preset "$PRESET" -crf "$CRF" -svtav1-params "lp=$THREADS:$SVT_EXTRA" -threads "$THREADS" \
    -pix_fmt "$pixfmt_out" -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" -movflags +faststart "$tmp" >"$ERRLOG" 2>&1 &
  CUR_PID=$!; CUR_TMP="$tmp"
  wait "$CUR_PID"; rc=$?; CUR_PID=""; CUR_TMP=""
  T1=$(date +%s); et=$((T1 - T0))
  if [ $rc -ne 0 ] || [ ! -s "$tmp" ]; then
    log "  ❌ 编码失败（rc=$rc），保留原文件；ffmpeg 末尾输出："
    tail -3 "$ERRLOG" 2>/dev/null | sed 's/^/      /' | tee -a "$LOG"
    rm -f "$tmp"; state_put "fail" "$rel" "$sz" "-" "-" "${dur:-0}"; FAIL=$((FAIL+1)); continue
  fi
  nsz=$(stat -c %s "$tmp")
  log "  编码完成：$(human "$sz") → $(human "$nsz")（省 $(awk -v a=$sz -v b=$nsz 'BEGIN{printf "%.0f%%",(a-b)/a*100}')），用时 $((et/60))m$((et%60))s"

  # ── 校验：时长一致 + 抽样 SSIM 达标 + 省幅达标，任一不过就保留原文件 ──
  ndur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$tmp" 2>/dev/null)
  save_pct=$(awk -v a="$sz" -v b="$nsz" 'BEGIN{printf "%.1f",(a-b)/a*100}')
  ssim=""; ok=1
  if ! awk -v a="${dur:-0}" -v b="${ndur:-0}" 'BEGIN{d=(a>b?a-b:b-a); exit !(a>0 && (d<=1.0 || d/a<=0.005))}'; then
    log "  ⏭ 时长不一致（源 ${dur}s vs 新 ${ndur}s），保留原文件"; ok=0
  fi
  if [ "$ok" = 1 ]; then
    ssim=$(sample_ssim "$f" "$tmp" "${dur:-0}")
    if [ -n "$ssim" ]; then
      awk -v s="$ssim" -v m="$SSIM_MIN" 'BEGIN{exit !(s>=m)}' || { log "  ⏭ 画质不达标（SSIM $ssim < $SSIM_MIN），保留原文件"; ok=0; }
    else
      log "  ⚠ 抽样 SSIM 计算失败，仅按时长与省幅判定"
    fi
  fi
  if [ "$ok" = 1 ]; then
    awk -v p="$save_pct" -v m="$MIN_SAVE" 'BEGIN{exit !(p>=m)}' || { log "  ⏭ 只省 ${save_pct}%（阈值 ${MIN_SAVE}%），保留原文件"; ok=0; }
  fi
  if [ "$ok" != 1 ]; then
    rm -f "$tmp"; state_put "skip" "$rel" "$sz" "$nsz" "${ssim:--}" "${dur:-0}"; SKIP=$((SKIP+1)); continue
  fi
  log "  校验通过：时长 ${dur}s↔${ndur}s  SSIM ${ssim:-未测}  省 ${save_pct}%"

  # ── 替换：原文件入备份 → 新文件原子就位 ──
  # 输出统一是 MP4；若原扩展名不是 mp4/m4v（如 .avi/.mkv/.wmv/.ts），必须改名，
  # 否则"AVI 里装着 MP4"会让平台按 AVI 再转一次码，且浏览器不认。
  ext="${b##*.}"; final="$f"
  case ".${ext,,}" in
    .mp4|.m4v) final="$f";;
    *) final="$d/${b%.*}.mp4"
       [ -e "$final" ] && final="$d/${b%.*}-av1.mp4"
    ;;
  esac
  key=$(printf '%s' "$rel" | sha1sum | cut -c1-12)
  bak="$BACKUP_DIR/${key}__${b}"
  if mv -f "$f" "$bak" && mv -f "$tmp" "$final"; then
    printf '%s\t%s\t%s\t%s\t%s\n' "$rel" "$bak" "$sz" "$(date '+%F %T')" "$final" >> "$BACKUP_IDX"
    state_put "done" "$rel" "$sz" "$nsz" "${ssim:--}" "${dur:-0}" "$final"
    DONE=$((DONE+1)); SAVED=$((SAVED + sz - nsz))
    [ "$final" != "$f" ] && log "  ℹ 容器由 ${ext} 改为 mp4，文件名 → $(basename "$final")"
    log "  ✅ 已替换（原文件备份于 $bak，可 --rollback 回滚）"
  else
    log "  ❌ 替换失败！尝试恢复原文件"
    [ -f "$bak" ] && mv -f "$bak" "$f"
    rm -f "$tmp"; state_put "fail" "$rel" "$sz" "$nsz" "${ssim:--}" "${dur:-0}"; FAIL=$((FAIL+1))
  fi
done

# ── 汇总 + 报告 ───────────────────────────────────────────────────────────
echo
log "═══ 本次汇总：完成 $DONE · 跳过 $SKIP · 失败 $FAIL · 共省 $(human "$SAVED") ═══"
{
  echo "# AV1 瘦身记录"
  echo
  echo "- 更新：$(date '+%F %T')"
  echo "- 目录：\`$DIR\`（只处理迅雷下载）"
  echo "- 参数：SVT-AV1 preset=$PRESET crf=$CRF threads=$THREADS，校验 SSIM≥$SSIM_MIN"
  echo
  echo "| 状态 | 原体积 | 新体积 | 省 | SSIM | 时长 | 文件 |"
  echo "|---|---|---|---|---|---|---|"
  [ -f "$STATE" ] && awk -F'\t' '$1=="done"||$1=="rolledback"{
    printf "| %s | %.2f GB | %.2f GB | %.0f%% | %s | %.0fs | `%s` |\n", $1, $3/1073741824, $4/1073741824, ($3-$4)/$3*100, $5, $6, ($8?$8:$2)}' "$STATE" | tail -60
  echo
  echo "**累计**：$(awk -F'\t' '$1=="done"{o+=$3;n+=$4;c++} END{printf "%d 个文件，%.1f GB → %.1f GB，省 %.1f GB", c, o/1073741824, n/1073741824, (o-n)/1073741824}' "$STATE" 2>/dev/null)"
  echo
  echo "备份目录：\`$BACKUP_DIR\`（回滚：\`bash ~/Downloads/av1-slim.sh --rollback-all\`；清理：\`--purge-backups 7\`）"
} > "$REPORT_MD"
log "报告已更新：$REPORT_MD"
