一键脚本
bash <(curl -sL gitee.com/meimolihan/cmdbox/raw/master/sh/fan-video_highlight.sh) copy 3
bash <(curl -sL ../sh/fan-video_highlight.sh) copy 2
效果预览
补充说明
该脚本用于批量处理目录下的 MP4 视频,自动生成「高光时刻」:默认 OUTPUT=timeline 不切割视频,只按时间线生成 sidecar JSON 元数据 + 缩略图(最省空间,播放时 UI 定位原片时间线即可);设置 OUTPUT=clip 则切出独立 mp4 片段。启动时自动静默检查/安装 ffmpeg,支持按 MAXDEPTH 递归遍历子目录,并以 .highlight.stop 停止标记随时中断。适合粉丝视频、直播回放等长视频的本地「高光时刻」整理场景。
功能特点
- 自动装依赖:启动时自动检测 ffmpeg/ffprobe,缺失则按包管理器(apt / dnf / yum / pacman)静默安装;SKIP_DEPS=1 可跳过
- 双输出模式:OUTPUT=timeline 默认只生成 sidecar JSON + 缩略图(不占磁盘);OUTPUT=clip 才切割独立 mp4
- 目录遍历:MAXDEPTH 控制递归深度,自动跳过无 mp4 的子目录,数字/模式位置可互换
- 自动切片:按 SEC_PER_CLIP 目标时长均分,MAX_CLIPS 设上限,短于 15 秒的视频整体保留
- 智能命名:按片段在视频中的位置自动命名(开场高能 / 前期精彩 / 中点高潮 / 后期转折 / 结局高潮)
- 双切割模式:copy 流复制秒出(关键帧对齐);exact 用 libx264 重编码,帧级精确
- 缩略图:webp / jpg / both / 关闭均可,宽度、质量、截图时间点可调
- 断点续跑:已存在的片段/JSON/缩略图自动跳过,重复运行不重复处理
- 强制重做:FORCE 重切片段,THUMB_FORCE 只重截缩略图,互不干扰
- 磁盘回收:timeline 下 PURGE=1 自动删除旧的切割片段文件
- 自动降级:ffmpeg 无 libwebp 编码器时自动改用 jpg
- 失败隔离:单个片段失败不影响其它片段,每个目录结束输出统计汇总
- 随时停止:放置
.highlight.stop标记文件,脚本处理完当前目录后自动退出 - 输出集中存放各目录下的 .highlights/ 子目录,不污染源视频目录
参数说明
| 变量 | 默认值 | 说明 |
|---|---|---|
| MODE | copy | 位置参数:copy=流复制快速 / exact=帧级精确重编码(libx264 crf23 + aac128k) |
| MAXDEPTH | 1 | 位置参数(正整数):递归遍历子目录深度,默认只处理当前目录 |
| OUTPUT | timeline | timeline=只生成 sidecar JSON + 缩略图 / clip=切割独立 mp4 片段 |
| PURGE | 0 | 仅 timeline 下发用:1=删除旧的切割片段文件回收磁盘 |
| MAX_CLIPS | 8 | 单视频最多切割片段数 |
| SEC_PER_CLIP | 30 | 目标每段时长(秒),短视频自动缩减 |
| FORCE | 0 | 1=强制重切全部片段 |
| THUMB | webp | 缩略图格式:webp / jpg / both / 0(0=不生成) |
| THUMB_WIDTH | 720 | 宽度,高度等比缩放(scale=720:-2) |
| THUMB_QUALITY | 80 | 仅对 webp 生效,libwebp 质量 |
| THUMB_TIME | 3 | 截图时间点(秒);片段短于该值时取中点 |
| THUMB_FORCE | 0 | 1=强制重截缩略图 |
用法示例
# 默认 timeline:只生成 sidecar JSON + 缩略图,不切割视频
bash fan-video_highlight.sh copy 2
# 切独立 mp4 片段(仅需离线片段/分享时使用)
OUTPUT=clip bash fan-video_highlight.sh exact 3
# 参数顺序不敏感,与上面等价
bash fan-video_highlight.sh 2 copy
# 只刷新缩略图(片段不动)
THUMB_FORCE=1 bash fan-video_highlight.sh
# 全部重做(重切片段 + 重截缩略图)
FORCE=1 THUMB_FORCE=1 OUTPUT=clip bash fan-video_highlight.sh
# 换格式/尺寸/质量
THUMB=both THUMB_WIDTH=480 THUMB_QUALITY=70 THUMB_FORCE=1 bash fan-video_highlight.sh
# 跳过依赖安装检查
SKIP_DEPS=1 bash fan-video_highlight.sh copy
输出说明
| 内容 | 说明 |
|---|---|
.highlights/ |
输出目录,自动创建 |
<名>_NN_<标题>.json |
sidecar 元数据(title / start_time / end_time / score),timeline 模式的核心输出 |
<名>_NN_<标题>.mp4 |
片段文件(OUTPUT=clip 时生成),NN 为两位序号(如 long_01_前期精彩.mp4) |
<名>_NN_<标题>.thumb.webp/.jpg |
对应片段的缩略图,与片段同名 |
注意事项
- 启动即检查/安装依赖,需 root 权限(sudo 下执行);SKIP_DEPS=1 可跳过
- MODE(copy/exact)与 MAXDEPTH(正整数)是位置参数且顺序可互换;其余配置用环境变量写在命令前
- 默认只处理当前目录(MAXDEPTH=1);OUTPUT=timeline 时不生成 mp4 片段,播放由 UI 定位原片时间线
- PURGE=1 会永久删除旧的切割片段文件,请谨慎使用
- copy 模式按关键帧切割,起点不一定逐帧精确;exact 模式帧级精确但耗时更长
- 片段时长小于 15 秒的整体保留为「完整片段」
- 每个目录结束时输出统计:成功 / 失败 / 跳过;缩略图另行统计
- 停止标记
.highlight.stop需放在递归起始目录,处理完当前目录后自动清除并退出 - 建议先小范围试跑一次确认命名与质量,再全量执行
高级使用方法
遍历“/vol2/1000/Video”下的子目录,直接使用脚本内置的深度参数,无需再手动定义循环(模式与深度顺序可互换):
cd /vol2/1000/Video
bash fan-video_highlight.sh copy 3
# 等价:bash fan-video_highlight.sh 3 copy
-
启动脚本:正常运行
-
想要强制停止,新开一个终端,在相同目录执行:
touch .highlight.stop
脚本会在处理完当前这个目录后,自动退出,不再进入下一个文件夹。
脚本源码
#!/bin/bash
set -u
shopt -s nullglob
install_deps() {
if [[ "${SKIP_DEPS:-0}" == "1" ]]; then
echo "SKIP_DEPS=1,跳过依赖安装检查"
return 0
fi
local need_install=0
if ! command -v ffmpeg &/dev/null; then
need_install=1
fi
if ! command -v ffprobe &/dev/null; then
need_install=1
fi
if [[ $need_install -eq 0 ]]; then
echo "✅ ffmpeg / ffprobe 已安装,无需处理"
return 0
fi
echo "⚠️ 检测缺失 ffmpeg/ffprobe,开始静默安装..."
if command -v apt &/dev/null; then
apt update -qq >/dev/null 2>&1
apt install -y -qq ffmpeg >/dev/null 2>&1
elif command -v dnf &/dev/null; then
dnf install -y ffmpeg >/dev/null 2>&1
elif command -v yum &/dev/null; then
yum install -y ffmpeg >/dev/null 2>&1
elif command -v pacman &/dev/null; then
pacman -S --noconfirm ffmpeg >/dev/null 2>&1
else
echo "❌ 不支持当前系统包管理器,请手动安装 ffmpeg"
exit 1
fi
if ! command -v ffmpeg &/dev/null || ! command -v ffprobe &/dev/null; then
echo "❌ 依赖安装失败,请手动安装 ffmpeg"
exit 1
fi
echo "✅ ffmpeg 安装完成"
}
MODE="copy"
MAXDEPTH=1
STOP_FILE="./.highlight.stop"
OUTPUT="${OUTPUT:-timeline}"
MAX_CLIPS="${MAX_CLIPS:-8}"
SEC_PER_CLIP="${SEC_PER_CLIP:-30}"
FORCE="${FORCE:-0}"
THUMB="${THUMB:-webp}"
THUMB_WIDTH="${THUMB_WIDTH:-720}"
THUMB_QUALITY="${THUMB_QUALITY:-80}"
THUMB_TIME="${THUMB_TIME:-3}"
THUMB_FORCE="${THUMB_FORCE:-0}"
PURGE="${PURGE:-0}"
parse_args() {
for arg in "$@"; do
if [[ "$arg" =~ ^[0-9]+$ ]]; then
MAXDEPTH="$arg"
elif [[ "$arg" == "copy" || "$arg" == "exact" ]]; then
MODE="$arg"
fi
done
}
process_single_dir() {
local dir="$1"
echo -e "\n===== 进入目录:$dir ====="
(
cd "$dir" || exit 1
echo "== 配置: OUTPUT=$OUTPUT MODE=$MODE THUMB=$THUMB FORCE=$FORCE THUMB_FORCE=$THUMB_FORCE =="
mkdir -p .highlights
# 检测webp支持
if [ "$THUMB" = "webp" ] || [ "$THUMB" = "both" ]; then
if ! ffmpeg -hide_banner -encoders 2>/dev/null | grep -q libwebp; then
echo "警告: ffmpeg 不支持 libwebp,改用 jpg"
THUMB="jpg"
fi
fi
ok=0; fail=0; skip=0
thumb_ok=0; thumb_fail=0; thumb_skip=0
for video in *.mp4; do
[ -f "$video" ] || continue
stem="${video%.*}"
dur_s=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$video" 2>/dev/null)
case "$dur_s" in ''|*[!0-9.]*) echo "跳过 ${video}"; continue;; esac
dur=${dur_s%%.*}
if [ "$dur" -lt 15 ]; then
n=1; clip="$dur_s"
else
n=$(( dur / SEC_PER_CLIP + 1 ))
[ "$n" -gt "$MAX_CLIPS" ] && n="$MAX_CLIPS"
clip=$(( dur / n ))
[ "$clip" -gt "$SEC_PER_CLIP" ] && clip="$SEC_PER_CLIP"
fi
echo "${stem}: 时长 ${dur}s → ${n} 段 × ${clip}s"
i=0
while [ "$i" -lt "$n" ]; do
idx=$(printf "%02d" $((i+1)))
if [ "$dur" -lt 15 ]; then
start=0; title="完整片段"
else
start=$(awk -v i="$i" -v d="$dur_s" -v nn="$n" -v cl="$clip" \
'BEGIN{s=(i+0.5)/nn*d-cl/2;if(s<0)s=0;m=d-cl;if(s>m)s=(m>0?m:0);printf "%.0f",s}')
title=$(awk -v i="$i" -v nn="$n" 'BEGIN{
r=(i+0.5)*100/nn
if(r<12)t="开场高能"; else if(r>85)t="结局高潮";
else if(r>62)t="后期转折"; else if(r>=50)t="中点高潮";
else if(r<32)t="前期精彩"; else t="精彩片段"; print t}')
fi
base=".highlights/${stem}_${idx}_${title}"
out="${base}.mp4"
rebuilt=0
if [ "$OUTPUT" = "clip" ]; then
if [ -e "$out" ] && [ "$FORCE" != 1 ]; then
echo " 跳过 $(basename "$out")"
skip=$((skip+1))
else
if [ "$MODE" = "exact" ]; then
ffmpeg -hide_banner -loglevel error -y -ss "$start" -i "$video" -t "$clip" \
-c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k \
-avoid_negative_ts make_zero -movflags +faststart "$out" \
&& { ok=$((ok+1)); rebuilt=1; echo " ↳ $(basename "$out") @${start}s"; } \
|| { fail=$((fail+1)); echo " 失败: $out"; }
else
ffmpeg -hide_banner -loglevel error -ss "$start" -i "$video" -t "$clip" -c copy \
-avoid_negative_ts make_zero -movflags +faststart -y "$out" \
&& { ok=$((ok+1)); rebuilt=1; echo " ↳ $(basename "$out") @${start}s"; } \
|| { fail=$((fail+1)); echo " 失败: $out"; }
fi
fi
fi
st=$start
cdur=""
if [ "$OUTPUT" = "clip" ] && [ -e "$out" ]; then
cdur=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$out" 2>/dev/null)
case "$cdur" in ''|*[!0-9.]*) cdur="";; esac
fi
if [ -n "$cdur" ]; then
endt=$(awk -v s="$st" -v d="$cdur" 'BEGIN{printf "%.3f", s+d}')
else
endt=$(awk -v s="$st" -v cl="$clip" 'BEGIN{printf "%.3f", s+cl}')
fi
# 生成JSON元数据
if [ ! -e "$base.json" ] || [ "$rebuilt" = 1 ]; then
cat > "$base.json" <<EOF
{
"title": "$title",
"start_time": $st,
"end_time": $endt,
"score": 8.5
}
EOF
echo " 元数据 $(basename "$base.json") ($st → ${endt}s)"
[ "$OUTPUT" = "timeline" ] && ok=$((ok+1))
else
[ "$OUTPUT" = "timeline" ] && skip=$((skip+1))
fi
if [ "$OUTPUT" = "timeline" ] && [ "$PURGE" = 1 ] && [ -e "$out" ]; then
rm -f "$out"
echo " 清理 $(basename "$out")"
fi
if [ "$THUMB" != "0" ]; then
need=0
if [ "$rebuilt" = 1 ] || [ "$THUMB_FORCE" = 1 ]; then
need=1
else
case "$THUMB" in
webp) [ ! -e "${base}.thumb.webp" ] && need=1 ;;
jpg) [ ! -e "${base}.thumb.jpg" ] && need=1 ;;
both) { [ ! -e "${base}.thumb.webp" ] || [ ! -e "${base}.thumb.jpg" ]; } && need=1 ;;
esac
fi
if [ "$need" = 1 ]; then
if [ "$OUTPUT" = "clip" ]; then
thumb_src="$out"
if [ -n "$cdur" ] && awk -v d="$cdur" -v t="$THUMB_TIME" \
'BEGIN{exit !(d+0>=t+0)}'; then
ss="$THUMB_TIME"
else
ss=$(awk -v d="${cdur:-0}" 'BEGIN{printf "%.0f",d/2}')
fi
else
thumb_src="$video"
ss=$(awk -v s="$st" -v t="$THUMB_TIME" -v d="$dur_s" \
'BEGIN{x=s+t;if(x>=d-1)x=(d-1>0?d-1:0);printf "%.0f",x}')
fi
if [ "$THUMB" = "webp" ] || [ "$THUMB" = "both" ]; then
ffmpeg -hide_banner -loglevel error -ss "$ss" -i "$thumb_src" -frames:v 1 \
-vf "scale=${THUMB_WIDTH}:-2" -c:v libwebp -quality "$THUMB_QUALITY" \
-y "${base}.thumb.webp" \
&& { thumb_ok=$((thumb_ok+1)); echo " ✓ $(basename "$base").thumb.webp"; } \
|| { thumb_fail=$((thumb_fail+1)); echo " ✗ webp 失败"; }
fi
if [ "$THUMB" = "jpg" ] || [ "$THUMB" = "both" ]; then
ffmpeg -hide_banner -loglevel error -ss "$ss" -i "$thumb_src" -frames:v 1 \
-vf "scale=${THUMB_WIDTH}:-2" -q:v 3 -y "${base}.thumb.jpg" \
&& { thumb_ok=$((thumb_ok+1)); echo " ✓ $(basename "$base").thumb.jpg"; } \
|| { thumb_fail=$((thumb_fail+1)); echo " ✗ jpg 失败"; }
fi
else
thumb_skip=$((thumb_skip+1))
case "$THUMB" in
webp) echo " - 缩略图已存在" ;;
jpg) echo " - 缩略图已存在" ;;
both) echo " - 缩略图已存在" ;;
esac
fi
fi
i=$((i+1))
done
done
echo
echo "完成:$([ "$OUTPUT" = "timeline" ] && echo "成功 ${ok}(时间线片段),跳过 ${skip}" || echo "成功 ${ok},失败 ${fail},跳过 ${skip}")"
[ "$THUMB" != "0" ] && \
echo "缩略图:成功 ${thumb_ok},失败 ${thumb_fail},跳过 ${thumb_skip}"
)
}
main() {
install_deps
parse_args "$@"
echo "===== 全局配置:MODE=$MODE MAXDEPTH=$MAXDEPTH ====="
if compgen -G "./*.mp4" >/dev/null; then
process_single_dir "."
fi
find . -mindepth 1 -maxdepth "$MAXDEPTH" -type d -print0 | while IFS= read -r -d '' dir; do
if [[ -f "$STOP_FILE" ]]; then
echo -e "\n【检测到停止标记,终止任务】"
rm -f "$STOP_FILE"
return 0
fi
compgen -G "$dir/*.mp4" >/dev/null || continue
process_single_dir "$dir"
done
}
main "$@"
创建本地脚本
new_script="new_test.sh"
cat > "$new_script" << 'EOF'
#!/bin/bash
# 粘贴脚本源码
EOF
# 保留本地脚本,去掉 rm -f "$new_script"
chmod +x "$new_script" && ./"$new_script" && rm -f "$new_script"
相关命令
- fan-video_menu 媒体服务管理
- ffmpeg_video_info_list 视频信息
- ffmpeg_video_h264_all 批量转码
- ffmpeg_video_hvc1_all 批量转码
- fan-video_highlight 👈 当前所在位置