首次提交

This commit is contained in:
2026-09-28 16:09:31 +08:00
commit 4a6839319a
71 changed files with 12144 additions and 0 deletions

167
scripts/apply_update.sh Normal file
View File

@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# 一键更新脚本(在线上【项目根目录】运行)。
# 作用: 用 _new/ 里的新代码覆盖改过的文件, 并把新配置项【合并】进现有 config.json
# (只加缺失的键, 绝不动你已有的密钥/路径/已调过的值), 然后自检。
# 用法: 解压更新包到项目根目录后, 执行 bash apply_update.sh
set -u
ROOT="$(pwd)"
TS="$(date +%Y%m%d_%H%M%S)"
BAK="_backup_${TS}"
NEW="_new"
echo "==> 项目目录: $ROOT"
if [ ! -d "tool" ] || [ ! -f "config/config.json" ]; then
echo "✗ 当前目录不像项目根(缺 tool/ 或 config/config.json)。请 cd 到项目根再运行。"
exit 1
fi
if [ ! -d "$NEW" ]; then
echo "✗ 找不到 $NEW/ 目录。请先把更新包解压到项目根目录。"
exit 1
fi
# 0. 更新前先把本机 数据.jsonl + 超长待重跑列表 备份到 COS(不等每天10点的自动备份)。
# 更新代码本身不会碰 数据.jsonl, 这一步纯粹是"顺手让全局数据保持最新"(比如全局去重要用),
# COS 访问失败不阻断更新, 只提示。
echo "==> 更新前备份 数据.jsonl / 超长待重跑列表 到 COS"
python3 tools/upload_daily.py || echo " ⚠ 备份失败(不影响本次更新继续), 稍后可手动重跑: python3 tools/upload_daily.py"
FILES="main.py run_worker.py tool/process.py tool/merge.py tool/getsrt.py tool/whisper_manager.py tool/VideoProcessing/multithread_algo.py tool/Ocr/tencentcloud_ocr.py tool/job_cos.py config/config_utils.py"
# 1. 备份
echo "==> 备份到 $BAK/"
mkdir -p "$BAK/tool/VideoProcessing" "$BAK/tool/Ocr" "$BAK/config"
for f in $FILES; do
[ -f "$f" ] && cp -p "$f" "$BAK/$f"
done
cp -p config/config.json "$BAK/config/config.json"
# 2. 覆盖代码文件
echo "==> 更新代码文件"
for f in $FILES; do
if [ ! -f "$NEW/$f" ]; then
echo "✗ 更新包缺少 $NEW/$f, 中止(已备份, 未改动)"; exit 1
fi
cp -p "$NEW/$f" "$f"
echo " ✓ $f"
done
# 2b. 附带的助手脚本(有就拷, 没有不报错)
for f in tools/拆分超长.py tools/seed_jobs.py tools/upload_daily.py tools/release_machine.py tools/patch_config.py; do
if [ -f "$NEW/$f" ]; then
mkdir -p "$(dirname "$f")"
cp -p "$NEW/$f" "$f" && echo " ✓ $f"
fi
done
# 3. 合并配置(只加缺失键, 不覆盖已有值; 报告与推荐值不同的键)
echo "==> 合并 config.json 新配置项(保留你已有的密钥/路径/设置)"
python3 - "$ROOT/config/config.json" <<'PYEOF'
import json, sys
p = sys.argv[1]
d = json.load(open(p, encoding="utf-8"))
au = d.setdefault("audio", {})
sf = d.setdefault("splitframe", {})
sub = d.setdefault("subtitle", {})
ocr = d.setdefault("tencentcloud", {}).setdefault("ocr", {})
dp = d.setdefault("dispatch", {})
_bkt = (d.get("tencentcloud", {}).get("cos", {}) or {}).get("bucket", "asr-dig-data-1253181912")
NEW_KEYS = [
(ocr, "max_attempts", 4),
(ocr, "timeout", 30),
(ocr, "dispatch_log_every", 1000),
(au, "cpu_threads", 2),
(sf, "crop_bottom_ratio", None),
(sub, "min_duration_seconds", 300),
(sub, "no_composite", False),
(sub, "merge_workers", 8),
(sub, "max_tiles_per_composite", 24),
(sub, "ocr_max_image_side", 4000),
(sub, "cv_detect", True),
(sub, "cv_use_region", False),
(sub, "filter_static", True),
(sub, "static_frac", 0.25),
(sub, "filter_lowconf", True),
(sub, "lowconf_thr", 80),
(sub, "lowconf_filter_frac", 0.6),
(sub, "lowconf_min_det", 200),
(sub, "strip_static_prefix", False),
(sub, "filter_watermark_pos", True),
(sub, "wm_dominant_frac", 0.25),
(sub, "wm_min_count", 20),
(sub, "split_far_boxes", True),
(sub, "max_duration_seconds", 5400),
(sub, "long_video_list", "./超长待重跑.txt"),
(sub, "ocr_no_drop", True),
(sub, "ocr_max_passes", 5),
(sub, "ocr_retry_sleep", 2),
(sub, "ocr_fail_list", "./OCR失败待重跑.txt"),
(sub, "no_subtitle_reasons", ["字幕太少","冗余文字过多","冗余文字太多","无字幕区域","非字幕内容","置信度太低","未检测","特殊字体","视频过短","视频过长"]),
(sf, "ffmpeg_timeout", 3600),
(sf, "ffmpeg_threads", "auto"),
(sf, "gpu_skip_indices", []),
(sf, "gpu_auto_select", True),
(sf, "gpu_busy_mem_mb", 4096),
(dp, "enabled", True),
(dp, "source", "cos"),
(dp, "run_long_video", False),
(dp, "auto_fallback_long_video", True),
(dp, "global_dedup_on_startup", True),
(dp, "machine_id", ""),
(dp, "jobs_root", "cos://%s/_jobs" % _bkt),
(dp, "shard_size", 2000),
(dp, "keep_alive", True),
(dp, "idle_poll_seconds", 60),
(dp, "claim_shuffle", True),
(dp, "claim_match", ""),
(dp, "heartbeat_seconds", 300),
(dp, "daily_backup_path", "cos://%s/_jobs/daily" % _bkt),
(dp, "long_list_backup_path", "cos://%s/_jobs/long_video" % _bkt),
(dp, "monitor_root", "cos://%s/_jobs/monitor" % _bkt),
(dp, "long_list_clear_after_upload", True),
(dp, "long_list_min_batch", 1000),
(dp, "daily_backup_hour", 10),
(dp, "stop_flag_key", "_jobs/STOP"),
]
added, differ = [], []
for sec, k, v in NEW_KEYS:
if k not in sec:
sec[k] = v; added.append((k, v))
elif sec[k] != v:
differ.append((k, sec[k], v))
json.dump(d, open(p, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(" 新增键:", ", ".join(f"{k}={v}" for k, v in added) or "(无, 已都存在)")
if differ:
print(" ⚠ 以下键你线上已有值, 未改动(如想用推荐值请手动改):")
for k, cur, rec in differ:
print(f" {k}: 当前={cur} 推荐={rec}")
PYEOF
if [ $? -ne 0 ]; then
echo "✗ 配置合并失败, 正在回滚 config.json"
cp -p "$BAK/config/config.json" config/config.json
exit 1
fi
# 4. 自检
echo "==> 自检 import"
if python3 -c "import main, run_worker, tool.job_cos, tool.merge, tool.process, tool.getsrt, tool.whisper_manager, tool.VideoProcessing.multithread_algo; print(' ✓ import OK')"; then
:
else
echo "✗ import 失败! 正在回滚所有改动"
for f in $FILES; do cp -p "$BAK/$f" "$f"; done
cp -p "$BAK/config/config.json" config/config.json
echo "已回滚到更新前。备份在 $BAK/"
exit 1
fi
echo ""
echo "============================================="
echo "✓ 更新完成。备份在: $BAK/"
echo " 回退: 把 $BAK/ 里的文件拷回原位即可。"
echo ""
echo "下一步: 先小批量验证, 没问题再上量。"
echo " python main.py --source-list ./text2 --max-workers 32 --executor process"
echo "============================================="

108
scripts/benchmark.sh Normal file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# 新机器上线前的算力/IO摸底测试(跟 scripts/diag.sh 不是一回事 —— diag.sh 是排查"已经在跑但
# 出问题"的机器;这个是给"还没开始跑正式任务、想先估算这台机器大概能跑多快"的新机器用的)。
# 测: CPU/内存/GPU(含真实硬解测试)/磁盘顺序读写速度/到OCR端点的网络延迟, 汇总成一份文件。
#
# 用法(项目根目录): bash scripts/benchmark.sh
# 产物: benchmark_YYYYmmdd_HHMMSS.txt (把这个文件发回来)
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 1
OUT="benchmark_$(date +%Y%m%d_%H%M%S).txt"
PY="$(command -v python || command -v python3)"
FFMPEG="$($PY -c "from config.config_utils import get_ffmpeg; print(get_ffmpeg())" 2>/dev/null || command -v ffmpeg)"
exec > >(tee "$OUT") 2>&1
echo "================ 算力/IO摸底 $(date) ================"
echo "主机: $(hostname)"
echo ""
echo "========== 1. CPU =========="
echo "[核数] $(nproc)"
if command -v lscpu >/dev/null 2>&1; then
lscpu | grep -E "Model name|CPU\(s\)|MHz|Thread|Socket"
fi
echo ""
echo "========== 2. 内存 =========="
free -h
echo ""
echo "========== 3. GPU =========="
if command -v nvidia-smi >/dev/null 2>&1; then
echo "[列表]"
nvidia-smi --query-gpu=index,name,memory.total,memory.used,utilization.gpu,driver_version --format=csv
echo ""
echo "[真实硬解测试: ffmpeg合成测试图案, 用CPU软编码生成素材(不碰GPU), 再用 -hwaccel cuda 解码,"
echo " 每张卡各测一遍 —— 生产流程只用GPU【解码】下载好的视频, 从不用GPU编码, 所以这里故意不测nvenc编码,"
echo " 只测跟生产完全一致的硬解路径]"
if [ -x "$FFMPEG" ] || command -v "$FFMPEG" >/dev/null 2>&1; then
n_gpu=$(nvidia-smi --query-gpu=index --format=csv,noheader | wc -l)
testfile="/tmp/_bench_src.mp4"
"$FFMPEG" -hide_banner -loglevel error -f lavfi -i "testsrc=size=1920x1080:rate=30:duration=10" \
-c:v libx264 -preset veryfast -f mp4 "$testfile" -y 2>&1 | tail -3
if [ -f "$testfile" ]; then
for dev in $(seq 0 $((n_gpu - 1))); do
echo " -- GPU $dev --"
t0=$(date +%s.%N)
"$FFMPEG" -hide_banner -loglevel error -hwaccel cuda -hwaccel_device "$dev" \
-i "$testfile" -f null - 2>&1 | tail -5
t1=$(date +%s.%N)
echo " 硬解读回耗时: $(echo "$t1 - $t0" | bc 2>/dev/null || echo "?")s (10秒素材, 越接近0越好, 明显>10s说明硬解没吃上或GPU很忙; 有报错说明硬解不可用, 会自动回退CPU软解)"
done
rm -f "$testfile"
else
echo " CPU软编码生成测试素材都失败了(跟GPU无关, 检查ffmpeg本身/libx264), 跳过硬解测试"
fi
else
echo " 找不到 ffmpeg, 跳过硬解测试"
fi
else
echo " 没有 nvidia-smi(不是GPU机器, 或驱动没装)"
fi
echo ""
echo "========== 4. 磁盘顺序读写速度(当前目录所在盘) =========="
TESTFILE="./_bench_io.tmp"
echo "[写] 顺序写1GB"
sync
dd if=/dev/zero of="$TESTFILE" bs=1M count=1024 conv=fdatasync 2>&1 | tail -3
echo "[读] 顺序读(先清缓存再读更准, 没权限清缓存就直接读)"
( sync && echo 3 > /proc/sys/vm/drop_caches ) 2>/dev/null
dd if="$TESTFILE" of=/dev/null bs=1M 2>&1 | tail -3
rm -f "$TESTFILE"
echo ""
echo "[磁盘剩余空间]"
df -h .
echo ""
echo "========== 5. 网络: 到OCR端点的延迟/连通率 =========="
if [ -f config/config.json ] && [ -n "$PY" ]; then
"$PY" tools/查端点存活.py --tcp --timeout 3 2>&1
else
echo " 跳过(找不到 python 或 config/config.json)"
fi
echo ""
echo "========== 6. 网络: 到COS的连通性 =========="
if command -v coscli >/dev/null 2>&1 || [ -x ./coscli-linux-amd64 ]; then
COSCLI="$(command -v coscli || echo ./coscli-linux-amd64)"
COSCFG="$($PY -c "from config.config_utils import config;print((config.get_splitframe_config() or {}).get('cosconfig_path','.cos.yaml'))" 2>/dev/null || echo .cos.yaml)"
JOBS_ROOT="$($PY -c "from config.config_utils import config;print((config.get_dispatch_config() or {}).get('jobs_root',''))" 2>/dev/null)"
if [ -n "$JOBS_ROOT" ]; then
echo "[列 $JOBS_ROOT 耗时]"
t0=$(date +%s.%N)
"$COSCLI" -c "$COSCFG" ls "$JOBS_ROOT/" 2>&1 | tail -5
t1=$(date +%s.%N)
echo " 耗时: $(echo "$t1 - $t0" | bc 2>/dev/null || echo "?")s (纯网络往返延迟参考, 不是下载带宽)"
else
echo " dispatch.jobs_root 未配置, 跳过"
fi
else
echo " 找不到 coscli, 跳过"
fi
echo ""
echo "================ 摸底结束 ================"
echo ""
echo ">>> 报告已保存到: $OUT (把这个文件发回来)"

48
scripts/build_bundle.sh Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# =============================================================================
# 一键构建离线部署包(在【能联网】的打包机上跑)
#
# setup_server.sh(装环境) → make_portable.sh(自包含) → pack.sh(打包) 三步合一
#
# 用法:
# bash build_bundle.sh cpu # CPU 版包:johnvansickle ffmpeg,老 glibc 也能跑
# bash build_bundle.sh gpu # GPU 版包:BtbN cuda ffmpeg,目标机需 glibc>=2.28
# bash build_bundle.sh gpu --whisper-gpu # GPU 版 + 把 CUDA 库打进包(目标机无系统 CUDA 也能 whisper GPU)
#
# 产物: /tmp/ocr_offline_bundle.tar.gz
# 拷到目标机后: bash deploy.sh /tmp/ocr_offline_bundle.tar.gz /opt
# =============================================================================
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VARIANT="${1:-cpu}" # cpu | gpu
WHISPER_GPU_FLAG=0
[ "${2:-}" = "--whisper-gpu" ] && WHISPER_GPU_FLAG=1
log() { echo -e "\n\033[1;36m##### $* #####\033[0m"; }
case "$VARIANT" in
cpu) FFV=cpu ;;
gpu) FFV=gpu ;;
*) echo "用法: bash build_bundle.sh [cpu|gpu] [--whisper-gpu]"; exit 1 ;;
esac
SD="$(cd "$(dirname "$0")" && pwd)"
log "构建 $VARIANT 版离线包 (whisper_gpu=$WHISPER_GPU_FLAG)"
# 1) 装基础环境(已装过会跳过大部分)
log "1/3 setup_server.sh(装 gcc/uv/python/依赖)"
bash "$SD/setup_server.sh"
# 2) 转自包含(python+模型+ffmpeg 进项目)
log "2/3 make_portable.sh(FFMPEG_VARIANT=$FFV)"
FFMPEG_VARIANT="$FFV" WHISPER_GPU="$WHISPER_GPU_FLAG" bash "$SD/make_portable.sh"
# 3) 打包(按变体命名,cpu/gpu 两个包不会互相覆盖)
OUT="/tmp/ocr_bundle_${VARIANT}.tar.gz"
log "3/3 pack.sh(打成 tar → $OUT)"
bash "$SD/pack.sh" "$OUT"
echo
echo "✅ 完成。产物: $OUT"
echo " 拷到目标机后: bash $SD/deploy.sh $OUT /opt"

75
scripts/deploy.sh Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# =============================================================================
# 离线机部署脚本(在【不能联网】的机器上跑)
#
# 自动识别打包模式:
# A) 自包含包(含项目内 python/):解压到【任意目标路径】,默认当前目录。
# bash deploy.sh 包.tar.gz [目标父目录] # 例: bash deploy.sh bundle.tar.gz /opt
# B) venv 包:解压到 / 还原到相同绝对路径(要求同路径/同用户/同架构)。
# bash deploy.sh 包.tar.gz
#
# 前提:与打包机【同 CPU 架构(x86_64)】且【glibc 不比打包机老】。
# 离线机不需要 uv / python / gcc / 联网。
# =============================================================================
set -euo pipefail
BUNDLE="${1:-/tmp/ocr_offline_bundle.tar.gz}"
TARGET="${2:-$(pwd)}" # 自包含模式的解压目标父目录
log() { echo -e "\n\033[1;32m==> $*\033[0m"; }
warn() { echo -e "\033[1;33m[警告] $*\033[0m"; }
[ -f "$BUNDLE" ] || { echo "找不到包: $BUNDLE"; exit 1; }
log "[1/3] 兼容性 + 模式识别"
echo " 架构: $(uname -m)(需与打包机一致,一般 x86_64)"
echo " glibc: $(ldd --version 2>/dev/null | head -1 | awk '{print $NF}')(不能比打包机老)"
# 看包里第一个条目:以 / 或 data/ 之类绝对路径开头 = venv 模式;以纯目录名开头 = 自包含
# 注意:head 关管道会让 tar 收到 SIGPIPE 返回非零,配合 set -e/pipefail 会误退出,故加 || true
FIRST="$(tar tzf "$BUNDLE" 2>/dev/null | head -1 || true)"
echo " 包内首条目: $FIRST"
PROJDIR=""
if printf '%s' "$FIRST" | grep -qE '^(/|root/|home/|data/|usr/|opt/)'; then
# ---------- venv 模式:解压到 / ----------
log "[2/3] venv 模式 → 解压到 / (还原到原绝对路径)"
tar xzf "$BUNDLE" -C /
# 项目目录 = 第一条目里到 ocr_data 那一段的绝对路径(取含项目名的目录)
PROJDIR="/$(printf '%s' "$FIRST" | sed -E 's#(.*/[^/]*ocr[_-]?data)/.*#\1#')"
warn "venv 模式:必须解压到相同路径/同用户,否则 venv 失效"
else
# ---------- 自包含模式:解压到目标目录 ----------
log "[2/3] 自包含模式 → 解压到 $TARGET"
mkdir -p "$TARGET"
tar xzf "$BUNDLE" -C "$TARGET"
TOP="$(printf '%s' "$FIRST" | cut -d/ -f1)"
PROJDIR="$TARGET/$TOP"
fi
echo " 项目目录: $PROJDIR"
log "[3/3] 离线自检"
cd "$PROJDIR" || { echo "进项目目录失败: $PROJDIR"; exit 1; }
# shellcheck disable=SC1091
source activate.sh
ok=1
ck() { if eval "$2" >/dev/null 2>&1; then echo " ✓ $1"; else echo " ✗ $1"; ok=0; fi; }
ck "python" "python --version"
ck "import cv2(headless)" "python -c 'import cv2'"
ck "import numpy" "python -c 'import numpy'"
ck "import faster_whisper" "python -c 'import faster_whisper'"
ck "import qcloud_cos" "python -c 'import qcloud_cos'"
ck "ffmpeg(bin/)" "ffmpeg -version"
ck "ffmpeg 支持 AV1" "ffmpeg -hide_banner -decoders | grep -iE 'dav1d|av1'"
ck "coscli" "./coscli-linux-amd64 --help"
MODEL="$(python -c "import json;print(json.load(open('config/config.json'))['audio'].get('model_size','small'))" 2>/dev/null || echo small)"
ck "whisper 模型离线加载" "python -c \"from faster_whisper import WhisperModel; WhisperModel('$MODEL',device='cpu',compute_type='int8')\""
echo
if [ "$ok" -eq 1 ]; then
log "全部就绪 ✅ 可离线运行"
echo " cd $PROJDIR && source activate.sh"
echo " python main.py --source-list ./text2 --max-workers 12"
else
warn "有项目未通过(见上面 ✗)。常见原因:架构/glibc 不匹配;venv 模式下路径/用户不一致"
fi

152
scripts/diag.sh Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# 一键诊断: 收集机器/进程/内存/OOM/日志/数据 全部信息到一个文件, 发回给我分析。
# 用法(在项目根目录): bash scripts/diag.sh
# 产物: diag_YYYYmmdd_HHMMSS.txt
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 1
OUT="diag_$(date +%Y%m%d_%H%M%S).txt"
PY="$(command -v python || command -v python3)"
# 所有输出同时进文件
exec > >(tee "$OUT") 2>&1
echo "================ 诊断报告 $(date) ================"
echo "主机: $(hostname) 目录: $(pwd)"
echo ""
echo "========== 1. 机器资源 =========="
echo "[内存 free -g]"; free -g
echo ""
echo "[CPU核数] $(nproc)"
echo "[负载 uptime] $(uptime)"
echo ""
echo "========== 2. OOM 检查(夜里有没有被系统杀) =========="
echo "[dmesg killed process]"
( dmesg -T 2>/dev/null || dmesg 2>/dev/null ) | grep -iE "killed process|out of memory|oom-kill" | tail -20 || echo " (无 / dmesg无权限)"
echo "[/var/log/messages oom]"
grep -iE "killed process|out of memory|oom" /var/log/messages 2>/dev/null | tail -10 || echo " (无 / 无权限)"
echo ""
echo "========== 3. 进程状态 =========="
echo "[main.py 进程数] $(ps -eo args 2>/dev/null | grep -c '[m]ain.py')"
echo "[ffmpeg 进程数] $(ps -eo comm 2>/dev/null | grep -c '[f]fmpeg')"
echo "[forkserver/worker] $(ps -eo args 2>/dev/null | grep -c '[m]ultiprocessing\|[f]orkserver')"
echo ""
echo "[吃内存最多的10个进程 RSS]"
ps -eo pid,rss,etime,args --sort=-rss 2>/dev/null | head -11
echo ""
echo "========== 4. Python / 环境 =========="
echo "[python] $($PY --version 2>&1) 路径: $PY"
echo "[which python] $(command -v python 2>/dev/null)"
echo "[自包含python在不在] $(ls python/bin/python3 2>/dev/null && echo yes || echo no)"
echo ""
echo "========== 5. config 关键值 =========="
if [ -f config/config.json ]; then
$PY - <<'PYEOF'
import json
try:
d=json.load(open("config/config.json",encoding="utf-8"))
s=d.get("subtitle",{}); sf=d.get("splitframe",{}); au=d.get("audio",{})
print(" ocr_workers=%s no_composite=%s merge_workers=%s max_tiles=%s"%(
s.get("ocr_workers"),s.get("no_composite"),s.get("merge_workers"),s.get("max_tiles_per_composite")))
print(" cv_detect=%s filter_static=%s filter_watermark_pos=%s split_far_boxes=%s"%(
s.get("cv_detect"),s.get("filter_static"),s.get("filter_watermark_pos"),s.get("split_far_boxes")))
print(" min_duration=%s crop_bottom_ratio=%s ffmpeg_threads=%s use_gpu=%s"%(
s.get("min_duration_seconds"),sf.get("crop_bottom_ratio"),sf.get("ffmpeg_threads"),sf.get("use_gpu")))
print(" audio.model_size=%s cpu_threads=%s endpoints数=%s"%(
au.get("model_size"),au.get("cpu_threads"),len(d.get("tencentcloud",{}).get("ocr",{}).get("endpoints",[]))))
except Exception as e:
print(" 读config失败:",e)
PYEOF
else
echo " config/config.json 不存在"
fi
echo "[启动命令] $(ps -eo args 2>/dev/null | grep '[m]ain.py' | head -1)"
echo ""
echo "========== 6. app.log 分析 =========="
if [ -f app.log ]; then
echo "[总行数] $(wc -l < app.log)"
echo "[关键词计数]"
for kw in 成功 无字幕 失败 暂停启动 抽帧完成 拼接完成 字幕完成 内存不足 BrokenProcess 崩溃 worker崩 Traceback Error 超时 timeout 冷却 端点; do
c=$(grep -c "$kw" app.log 2>/dev/null)
[ "${c:-0}" -gt 0 ] && printf " %-14s %s\n" "$kw" "$c"
done
echo ""
echo "[最近 40 行]"
tail -40 app.log
else
echo " app.log 不存在"
fi
echo ""
echo "========== 7. 数据.jsonl 分析 =========="
if [ -f 数据.jsonl ]; then
$PY - <<'PYEOF'
import json,statistics
from collections import Counter,defaultdict
from datetime import datetime,timedelta
latest={}
for line in open("数据.jsonl",encoding="utf-8"):
line=line.strip()
if not line: continue
try: r=json.loads(line)
except: continue
v=r.get("video_cos_path")
if v: latest[v]=r
n=len(latest)
st=Counter(r.get("status") for r in latest.values())
print(" 去重视频 %d | 状态 %s"%(n,dict(st)))
if n: print(" 成功率 %.1f%%"%(st.get("success",0)/n*100))
# 阶段耗时(成功)
tot=defaultdict(float); te=0
for r in latest.values():
if r.get("status")!="success": continue
sd=r.get("stage_durations") or {}; te+=r.get("elapsed_seconds") or 0
for k in ["split_frame","merge","getsrt"]:
if k in sd: tot[k]+=sd[k]
if te:
print(" 阶段占比: 抽帧%.0f%% 拼接%.0f%% OCR+字幕%.0f%%"%(tot["split_frame"]/te*100,tot["merge"]/te*100,tot["getsrt"]/te*100))
# OCR/帧
rs=[ic.get("total_ocr_count")/ic.get("frame_count") for r in latest.values() if (ic:=r.get("image_counts") or {}).get("frame_count") and ic.get("total_ocr_count")]
if rs: print(" OCR/帧 中位 %.3f"%statistics.median(rs))
# 时长
durs=sorted((r.get("duration") or 0) for r in latest.values() if r.get("duration"))
if durs: print(" 视频时长 中位%.0fs max%.0fs | >20分钟 %d个"%(statistics.median(durs),max(durs),sum(1 for d in durs if d>1200)))
# 单视频平均耗时
els=[r.get("elapsed_seconds") or 0 for r in latest.values() if r.get("status")=="success"]
if els: print(" 成功视频平均耗时 %.0fs 中位%.0fs"%(sum(els)/len(els),statistics.median(els)))
# 每小时吞吐 + 有效并发(重叠法)
busy=defaultdict(float); cnt=defaultdict(int)
for r in latest.values():
try: s=datetime.fromisoformat(r["process_start"]); e=datetime.fromisoformat(r["process_end"])
except: continue
if e<=s: continue
cnt[s.strftime("%m-%d %H")]+=1
cur=s
while cur<e:
hk=cur.strftime("%m-%d %H"); nx=cur.replace(minute=0,second=0,microsecond=0)+timedelta(hours=1)
seg=min(e,nx); busy[hk]+=(seg-cur).total_seconds(); cur=seg
print(" 每小时(完成数 / 有效并发):")
for hk in sorted(busy)[-8:]:
print(" %s : %d条 / 并发%.1f"%(hk,cnt[hk],busy[hk]/3600))
PYEOF
else
echo " 数据.jsonl 不存在"
fi
echo ""
echo "========== 8. 磁盘 / GPU =========="
echo "[磁盘 df -h .]"; df -h . 2>/dev/null
echo "[split/ 大小] $(du -sh split 2>/dev/null || echo '无')"
echo "[正在处理的视频数 split/] $(ls split 2>/dev/null | wc -l)"
if command -v nvidia-smi >/dev/null 2>&1; then
echo "[GPU]"; nvidia-smi --query-gpu=index,memory.used,memory.total,utilization.gpu --format=csv,noheader 2>/dev/null
fi
echo ""
echo "================ 诊断结束 ================"
echo ""
echo ">>> 报告已保存到: $OUT (把这个文件发回来)"

127
scripts/make_portable.sh Normal file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# =============================================================================
# 把环境改造成"项目内自包含"——Python、第三方库、模型全放进项目目录,
# 之后整个项目文件夹可以【随便放到任意路径、任意用户】,不再依赖 /root 或固定路径。
#
# 在【能联网】的机器上跑一次(已 setup_server.sh 装好 .venv 后):
# bash make_portable.sh # 默认 ffmpeg 用 GPU 版(BtbN,含 cuda/nvdec,也兼容 CPU)
# # 需运行机 glibc>=2.28;GPU 机器自动硬解、CPU 机器软解
# FFMPEG_VARIANT=cpu bash make_portable.sh # ffmpeg 用 CPU 版(johnvansickle,全静态,老 glibc 也能跑)
# 之后:
# bash pack.sh # 打包(此时只需打项目目录,已自包含)
# # 离线机解压到【任意路径】即可: source activate.sh && python main.py ...
# =============================================================================
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJ="$(pwd)"
PIP_MIRROR="${PIP_MIRROR:-https://repo.huaweicloud.com/repository/pypi/simple}"
log() { echo -e "\n\033[1;32m==> $*\033[0m"; }
warn() { echo -e "\033[1;33m[警告] $*\033[0m"; }
have() { command -v "$1" >/dev/null 2>&1; }
[ -d .venv ] || { echo "没有 .venv,请先 bash setup_server.sh"; exit 1; }
# shellcheck disable=SC1091
source .venv/bin/activate
have uv || { echo "需要 uv(本机改造用,离线机不需要)"; exit 1; }
# 0) 确保 bin/ffmpeg 可用。两种变体(用环境变量 FFMPEG_VARIANT 选):
# cpu (默认) = johnvansickle 静态版,含 dav1d(CPU 软解 AV1),兼容老 glibc,哪都能跑。
# gpu = BtbN 版,含 cuda/nvdec(GPU 硬解),给 Ampere+ 卡的机器用;需较新 glibc(>=2.28)。
# 用法: FFMPEG_VARIANT=gpu bash make_portable.sh
# 默认下 GPU 版(含 cuda/nvdec,也兼容 CPU 软解)。要纯 CPU 版用 FFMPEG_VARIANT=cpu。
FFMPEG_VARIANT="${FFMPEG_VARIANT:-gpu}"
FF_NEED=1
if [ "$FFMPEG_VARIANT" = "gpu" ]; then
FF_URL="${FFMPEG_GPU_URL:-https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz}"
# GPU 版(BtbN)要 glibc>=2.28 才能运行;本机若太老仅警告(可能在给别的新机器打包)
# 注意:管道里 head 会让前面命令 SIGPIPE,配合 set -e/pipefail 会误退出,故加 || true
_glibc="$(ldd --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1 || true)"
if [ -n "$_glibc" ] && [ "$(printf '%s\n2.28\n' "$_glibc" | sort -V | head -1)" != "2.28" ]; then
warn "本机 glibc=$_glibc < 2.28,GPU 版 ffmpeg 在本机跑不了(GLIBC_2.28 not found)。"
warn "仅当打包给 glibc>=2.28 的机器时才用 GPU 版;本机自用请: FFMPEG_VARIANT=cpu bash make_portable.sh"
fi
# 用 grep(不带 -q)读完整输出再判,避免 grep -q 提前关管道触发 SIGPIPE+pipefail 误判
if [ -x "$PROJ/bin/ffmpeg" ] && "$PROJ/bin/ffmpeg" -hide_banner -hwaccels 2>/dev/null | grep -i cuda >/dev/null; then
log "[0/4] bin/ffmpeg 已是 GPU 版(支持 cuda),跳过"; FF_NEED=0
else
log "[0/4] 下载 GPU 版 ffmpeg (BtbN,含 cuda/nvdec) → ./bin"
fi
else
FF_URL="${FFMPEG_STATIC_URL:-https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz}"
if [ -x "$PROJ/bin/ffmpeg" ] && "$PROJ/bin/ffmpeg" -hide_banner -decoders 2>/dev/null | grep -iE 'dav1d|av1' >/dev/null; then
log "[0/4] bin/ffmpeg 已支持 AV1(CPU 版),跳过"; FF_NEED=0
else
log "[0/4] 下载 CPU 版 ffmpeg (johnvansickle,含 dav1d) → ./bin"
fi
fi
if [ "$FF_NEED" -eq 1 ]; then
mkdir -p "$PROJ/bin"; tmp="$(mktemp -d)"
if have curl; then curl -L "$FF_URL" -o "$tmp/ff.tar.xz"; else wget -O "$tmp/ff.tar.xz" "$FF_URL"; fi
tar xf "$tmp/ff.tar.xz" -C "$tmp"
# johnvansickle 在 <dir>/ffmpeg,BtbN 在 <dir>/bin/ffmpeg —— 直接全树找,两种都兼容
ffb="$(find "$tmp" -type f -name ffmpeg | head -1)"
ffp="$(find "$tmp" -type f -name ffprobe | head -1)"
if [ -n "$ffb" ] && [ -n "$ffp" ]; then
cp "$ffb" "$ffp" "$PROJ/bin/" && chmod +x "$PROJ/bin/ffmpeg" "$PROJ/bin/ffprobe"
log "ffmpeg/ffprobe 已装到 ./bin($FFMPEG_VARIANT 版)"
else
echo "解压后没找到 ffmpeg/ffprobe,请手动放入 ./bin"; exit 1
fi
rm -rf "$tmp"
fi
# 1) 把 uv 的独立 Python 复制进项目 ./python(python-build-standalone 本身可重定位)
log "[1/4] 复制独立 Python 到项目内 ./python"
PYSRC="$(dirname "$(dirname "$(readlink -f .venv/bin/python)")")"
echo " 源: $PYSRC"
rm -rf "$PROJ/python"
cp -a "$PYSRC" "$PROJ/python"
PYBIN="$PROJ/python/bin/python3"
[ -x "$PYBIN" ] || PYBIN="$(ls "$PROJ"/python/bin/python3.* | head -1)"
echo " 项目内 Python: $PYBIN"
# 去掉 uv 的"外部管理(EXTERNALLY-MANAGED)"标记,否则 uv 拒绝往这个 Python 里装包
rm -f "$PROJ"/python/lib/python3.*/EXTERNALLY-MANAGED
# 2) 把依赖装进这个项目内 Python(脱离 .venv,用 headless opencv)
log "[2/4] 安装依赖到项目内 Python"
uv pip uninstall --python "$PYBIN" --break-system-packages opencv-python 2>/dev/null || true
uv pip install --python "$PYBIN" --break-system-packages -r requirements.txt --index-url "$PIP_MIRROR" \
--only-binary numpy,scipy,scikit-learn,opencv-python-headless,Pillow,av \
|| uv pip install --python "$PYBIN" --break-system-packages -r requirements.txt --index-url "$PIP_MIRROR"
# WHISPER_GPU=1:把 CUDA 库(cublas/cudnn)也装进项目 Python,这样目标机【无需系统 CUDA】
# 也能让 whisper 跑 GPU(ctranslate2 从 venv 里找库;activate.sh 会把它加进 LD_LIBRARY_PATH)。
if [ "${WHISPER_GPU:-0}" = "1" ]; then
log " 额外装 CUDA 库(nvidia-cublas/cudnn-cu12)给 whisper GPU 用"
uv pip install --python "$PYBIN" --break-system-packages \
nvidia-cublas-cu12 nvidia-cudnn-cu12 --index-url "$PIP_MIRROR" \
|| warn "CUDA 库安装失败;目标机若无系统 CUDA,whisper 将回退 CPU"
fi
# 3) 把 whisper 模型缓存搬进项目 ./models(用 HF_HOME 指过去)
log "[3/4] 预下载 whisper 模型到项目内 ./models"
MODEL="$(python -c "import json;print(json.load(open('config/config.json'))['audio'].get('model_size','small'))")"
mkdir -p "$PROJ/models"
HF_HOME="$PROJ/models" HF_HUB_OFFLINE=0 TRANSFORMERS_OFFLINE=0 \
HF_ENDPOINT="${HF_ENDPOINT:-https://hf-mirror.com}" \
HF_HUB_DISABLE_XET=1 \
"$PYBIN" -c "from faster_whisper import WhisperModel; WhisperModel('$MODEL',device='cpu',compute_type='int8'); print('模型已放进 ./models')"
# 4) 删掉旧 .venv(不再需要,已被项目内 Python 取代)
log "[4/4] 清理旧 .venv"
deactivate 2>/dev/null || true
rm -rf "$PROJ/.venv"
cat <<EOF
完成 ✅ 现在项目已自包含,可随便放任意路径/用户:
./python/ 独立 Python + 所有第三方库
./bin/ ffmpeg / ffprobe
./models/ whisper 模型
coscli-linux-amd64
验证: source activate.sh && python -c "import cv2,faster_whisper;print('OK')"
打包: bash pack.sh
EOF

33
scripts/monitor.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# 每隔 N 秒采集 CPU/IO/负载/网络,带时间戳写入一个文件。
# 用法: bash monitor.sh [间隔秒,默认5] [输出文件,默认 monitor.log]
# 停止: Ctrl-C 或 kill $(cat monitor.pid)
INTERVAL="${1:-5}"
OUT="${2:-monitor.log}"
echo $$ > monitor.pid
echo "===== 监控开始 $(date) 间隔${INTERVAL}s 输出 $OUT =====" | tee -a "$OUT"
command -v iostat >/dev/null 2>&1 && HAS_IOSTAT=1 || HAS_IOSTAT=0
command -v sar >/dev/null 2>&1 && HAS_SAR=1 || HAS_SAR=0
[ "$HAS_IOSTAT" = 0 ] && echo "(无 iostat, 只记 vmstat; 装: yum/apt install sysstat)" | tee -a "$OUT"
trap 'echo "===== 监控停止 $(date) =====" >> "$OUT"; rm -f monitor.pid; exit 0' INT TERM
while true; do
{
echo "----- $(date '+%Y-%m-%d %H:%M:%S') -----"
echo "[vmstat] r=运行队列 b=阻塞(等IO) wa=iowait% id=空闲%"
vmstat 1 2 | tail -1
if [ "$HAS_IOSTAT" = 1 ]; then
echo "[iostat] %util>90=磁盘吃满 w/s=写次数 w_await=写延迟ms"
# 取第二份报告(间隔值, 非开机累计), 只留设备行
iostat -dx 1 2 | awk '/Device/{c++} c==2'
fi
if [ "$HAS_SAR" = 1 ]; then
echo "[net] 网卡收发 kB/s:"
sar -n DEV 1 1 | awk '/Average:/ && $2!="lo" && $2!="IFACE" {printf " %s rx=%s tx=%s kB/s\n",$2,$5,$6}'
fi
echo ""
} >> "$OUT" 2>&1
sleep "$INTERVAL"
done

84
scripts/pack.sh Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# =============================================================================
# 离线部署打包脚本(在【能联网】的机器上跑)
#
# 两种模式自动识别:
# A) 自包含模式(已跑过 make_portable.sh,项目内有 ./python + ./models)
# → 打包里只含项目这一个文件夹,离线机解压到【任意路径/任意用户】即可。
# B) venv 模式(只有 .venv)
# → 打项目 + /root 下的 uv Python + HF 缓存,离线机要解压到相同路径。
#
# 用法: bash pack.sh
# 产物: /tmp/ocr_offline_bundle.tar.gz
# =============================================================================
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT_DIR="$(pwd)"
OUT="${1:-/tmp/ocr_offline_bundle.tar.gz}"
log() { echo -e "\n\033[1;32m==> $*\033[0m"; }
warn() { echo -e "\033[1;33m[警告] $*\033[0m"; }
have() { command -v "$1" >/dev/null 2>&1; }
PBASE="$(basename "$PROJECT_DIR")"
PPARENT="$(dirname "$PROJECT_DIR")"
# 运行时数据排除(部署不需要,打进去又慢又大)。用项目目录名作前缀,两种模式通用。
RT_DIRS=(split video composite coscli_output srt info info_error log)
EXCLUDES=(--exclude='*.log' --exclude='__pycache__' --exclude='*.pyc'
--exclude="$PBASE/数据.jsonl")
for d in "${RT_DIRS[@]}"; do EXCLUDES+=(--exclude="$PBASE/$d"); done
# 选最快的压缩器:pigz 多线程(输出仍是标准 gzip,离线机普通 tar xzf 就能解)
if have pigz; then
COMPRESS="pigz"; log "用 pigz 多线程压缩(快)"
else
COMPRESS="gzip -1"; warn "未装 pigz,用 gzip -1;想更快可先 yum install -y pigz"
fi
if [ -x "$PROJECT_DIR/python/bin/python3" ]; then
# ---------- A) 自包含:只打项目目录(用相对名),可解压到任意路径 ----------
log "自包含模式(项目内含 ./python + ./models)"
[ -d "$PROJECT_DIR/models" ] || warn "没看到 ./models,确认 make_portable.sh 跑完整了"
tar "${EXCLUDES[@]}" --use-compress-program="$COMPRESS" -cf "$OUT" \
-C "$PPARENT" "$PBASE"
MODE="自包含(解压到任意路径/用户)"
else
# ---------- B) venv:打项目(绝对路径) + 外部 Python + HF 缓存 ----------
[ -d .venv ] || { echo "既没有 ./python 也没有 .venv,请先 setup_server.sh 或 make_portable.sh"; exit 1; }
# shellcheck disable=SC1091
source .venv/bin/activate
log "venv 模式:确保 headless opencv + 预下载模型"
uv pip uninstall opencv-python 2>/dev/null || true
uv pip install opencv-python-headless \
--index-url "${PIP_MIRROR:-https://repo.huaweicloud.com/repository/pypi/simple}" 2>/dev/null \
|| echo "(headless 可能已装,忽略)"
MODEL="$(python -c "import json;print(json.load(open('config/config.json'))['audio'].get('model_size','small'))")"
HF_HUB_OFFLINE=0 TRANSFORMERS_OFFLINE=0 HF_ENDPOINT="${HF_ENDPOINT:-https://hf-mirror.com}" \
python -c "from faster_whisper import WhisperModel; WhisperModel('$MODEL',device='cpu',compute_type='int8'); print('模型已缓存')"
PYDIR="$(dirname "$(dirname "$(readlink -f .venv/bin/python)")")"
HFCACHE="${HF_HOME:-$HOME/.cache/huggingface}"
echo " uv Python: $PYDIR 模型缓存: $HFCACHE"
log "打包项目(绝对路径) + Python + 模型缓存"
# 项目用绝对路径(还原到原位),外部两目录同理
EXABS=(--exclude='*.log' --exclude='__pycache__' --exclude='*.pyc'
--exclude="${PROJECT_DIR#/}/数据.jsonl")
for d in "${RT_DIRS[@]}"; do EXABS+=(--exclude="${PROJECT_DIR#/}/$d"); done
tar "${EXABS[@]}" --use-compress-program="$COMPRESS" -cf "$OUT" \
-C / "${PROJECT_DIR#/}" -C / "${PYDIR#/}" -C / "${HFCACHE#/}"
MODE="venv(离线机需解压到相同路径/用户)"
fi
echo
log "完成 ✅ $OUT ($(du -h "$OUT" | cut -f1))"
echo "模式: $MODE"
cat <<EOF
下一步:
scp $OUT root@离线机:/tmp/
bash deploy.sh /tmp/$(basename "$OUT") # 自包含模式可加目标路径: bash deploy.sh 包 /opt
EOF

26
scripts/set_ffmpeg.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# 一键切换项目 bin/ffmpeg 的版本(本机自用 cpu / 打 GPU 包 gpu)
# bash temp/set_ffmpeg.sh cpu # johnvansickle 全静态,任何 glibc 都能跑(本机自用)
# bash temp/set_ffmpeg.sh gpu # BtbN 含 cuda/nvdec,需 glibc>=2.28(给 GPU 机器)
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # 项目根
V="${1:-cpu}"
BIN="$(pwd)/bin"; mkdir -p "$BIN"
have(){ command -v "$1" >/dev/null 2>&1; }
case "$V" in
cpu) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ;;
gpu) URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" ;;
*) echo "用法: bash temp/set_ffmpeg.sh [cpu|gpu]"; exit 1 ;;
esac
echo "下载 $V 版 ffmpeg ..."
tmp="$(mktemp -d)"
if have curl; then curl -L "$URL" -o "$tmp/ff.tar.xz"; else wget -O "$tmp/ff.tar.xz" "$URL"; fi
tar xf "$tmp/ff.tar.xz" -C "$tmp"
ffb="$(find "$tmp" -type f -name ffmpeg | head -1)"
ffp="$(find "$tmp" -type f -name ffprobe | head -1)"
cp "$ffb" "$ffp" "$BIN/"
chmod +x "$BIN/ffmpeg" "$BIN/ffprobe"
rm -rf "$tmp"
echo "已切到 $V 版: $("$BIN/ffmpeg" -version 2>&1 | head -1 || echo '本机跑不了(若是 gpu 版+老 glibc,正常)')"

244
scripts/setup_server.sh Normal file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env bash
# =============================================================================
# 视频字幕提取系统 — 服务器一键环境配置脚本
#
# 安装/配置:gcc 编译工具链、ffmpeg(含 AV1/dav1d 解码)、uv、Python 虚拟环境、
# 项目依赖,并处理常见环境坑(opencv libGL、UTF-8 locale、HF/pip 镜像、
# OMP 线程、文件句柄上限)。
#
# 用法(在项目根目录):
# bash setup_server.sh
# 跑完后:
# source activate.sh # 激活环境(设置 venv + 各种环境变量)
# python main.py --source-list ./text2 --max-workers 12
#
# 幂等:可重复运行,已装的会跳过。
# =============================================================================
set -euo pipefail
# ----------------------------- 可调参数 --------------------------------------
PYTHON_VERSION="${PYTHON_VERSION:-3.11}"
PIP_MIRROR="${PIP_MIRROR:-https://repo.huaweicloud.com/repository/pypi/simple}"
HF_ENDPOINT_URL="${HF_ENDPOINT_URL:-https://hf-mirror.com}"
FFMPEG_STATIC_URL="${FFMPEG_STATIC_URL:-https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz}"
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV_DIR="$PROJECT_DIR/.venv"
cd "$PROJECT_DIR"
# 让本脚本(及自检)优先用项目 bin/ 里的 ffmpeg(我们装的静态版)
export PATH="$PROJECT_DIR/bin:$PATH"
# ----------------------------- 工具函数 --------------------------------------
log() { echo -e "\n\033[1;32m==> $*\033[0m"; }
warn() { echo -e "\033[1;33m[警告] $*\033[0m"; }
err() { echo -e "\033[1;31m[错误] $*\033[0m" >&2; }
have() { command -v "$1" >/dev/null 2>&1; }
# root 则不用 sudo,否则用 sudo
if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi
# 检测包管理器
PKG=""
if have dnf; then PKG="dnf"
elif have yum; then PKG="yum"
elif have apt-get; then PKG="apt"
else warn "未识别的包管理器,跳过系统包安装(请确保已手动装好 gcc/ffmpeg 等)"; fi
# 解压后 coscli/bin 里的二进制常丢失 +x,先无条件加执行权限(放最前,确保一定执行)
for b in coscli-linux-amd64 coscli bin/ffmpeg bin/ffprobe; do
[ -f "$PROJECT_DIR/$b" ] && chmod +x "$PROJECT_DIR/$b" 2>/dev/null || true
done
log "已给 coscli / bin 二进制加执行权限"
# =============================================================================
log "[1/7] 安装系统依赖(gcc 工具链 + opencv/运行库)"
# =============================================================================
# 关键环境坑:
# - opencv-python 在无图形界面的服务器上 import 会报 libGL.so.1 缺失 → 装 mesa-libGL/libgl1
# - 还需要 glib2(libgthread-2.0) 否则 opencv 也可能报错
# - gcc/gcc-c++/make:少数包没有 wheel 时要本地编译
case "$PKG" in
dnf|yum)
# openblas-devel:万一 scipy/numpy 走源码编译时需要(优先还是用 wheel)
$SUDO $PKG install -y gcc gcc-c++ gcc-gfortran make git tar xz which \
mesa-libGL glib2 openblas-devel || warn "部分系统包安装失败,继续"
;;
apt)
$SUDO apt-get update -y || true
$SUDO apt-get install -y build-essential gfortran git tar xz-utils curl \
libgl1 libglib2.0-0 libopenblas-dev || warn "部分系统包安装失败,继续"
;;
esac
# =============================================================================
log "[2/7] 安装 ffmpeg(含 AV1/dav1d 解码) → 项目目录 bin/"
# =============================================================================
# 直接下载静态构建(johnvansickle,自带 dav1d)到项目的 bin/,代码会自动优先用它
# (config_utils 启动时把 bin/ 提到 PATH 最前)。不依赖系统 ffmpeg,也不用 sudo。
# 注意:CPU 版(无 NVDEC);要 GPU 硬解需另装 cuda 版 ffmpeg。
FFMPEG_DIR="$PROJECT_DIR/bin"
LOCAL_FFMPEG="$FFMPEG_DIR/ffmpeg"
need_ffmpeg=1
if [ -x "$LOCAL_FFMPEG" ] && "$LOCAL_FFMPEG" -hide_banner -decoders 2>/dev/null | grep -qiE 'dav1d|av1'; then
log "项目自带 ffmpeg 已存在且支持 AV1: $LOCAL_FFMPEG,跳过下载"
need_ffmpeg=0
fi
if [ "$need_ffmpeg" -eq 1 ]; then
if have curl || have wget; then
mkdir -p "$FFMPEG_DIR"
tmp="$(mktemp -d)"
log "下载 ffmpeg 静态包到 $FFMPEG_DIR ..."
if have curl; then curl -L "$FFMPEG_STATIC_URL" -o "$tmp/ff.tar.xz"
else wget -O "$tmp/ff.tar.xz" "$FFMPEG_STATIC_URL"; fi
tar -xf "$tmp/ff.tar.xz" -C "$tmp"
ffdir="$(find "$tmp" -maxdepth 1 -type d -name 'ffmpeg-*' | head -1)"
if [ -n "$ffdir" ]; then
cp "$ffdir/ffmpeg" "$ffdir/ffprobe" "$FFMPEG_DIR/"
chmod +x "$FFMPEG_DIR/ffmpeg" "$FFMPEG_DIR/ffprobe"
log "ffmpeg/ffprobe 已装到 $FFMPEG_DIR"
else
err "解压后未找到 ffmpeg 目录,请手动安装 ffmpeg"
fi
rm -rf "$tmp"
else
err "没有 curl/wget,无法下载 ffmpeg,请手动安装"
fi
fi
# =============================================================================
log "[3/7] 安装 uv"
# =============================================================================
if ! have uv; then
curl -LsSf https://astral.sh/uv/install.sh | sh
# uv 默认装到 ~/.local/bin 或 ~/.cargo/bin
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
fi
have uv || { err "uv 安装失败,请检查网络"; exit 1; }
log "uv 版本: $(uv --version)"
# =============================================================================
log "[4/7] 用 uv 安装 Python ${PYTHON_VERSION}"
# =============================================================================
uv python install "$PYTHON_VERSION" || warn "uv python install 失败(可能已装)"
# =============================================================================
log "[5/7] 创建虚拟环境并安装依赖"
# =============================================================================
# 优先用 pyproject.toml + uv.lock(规范、可复现);没有则回退 requirements.txt。
# pyproject 的 [tool.uv] 已配置:禁止 av/numpy/sklearn 本地编译 + 清华镜像。
# 用 uv venv + uv pip install(现代解析器)。--only-binary 让它自动挑有 wheel 的版本,
# 避免某些受限镜像把新版本只给源码包导致本地编译/报错(不像 uv sync 的 no-build 会硬失败)。
[ -d "$VENV_DIR" ] || uv venv --python "$PYTHON_VERSION" "$VENV_DIR"
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
if [ -f requirements.txt ]; then
uv pip install -r requirements.txt --index-url "$PIP_MIRROR" \
--only-binary numpy,scipy,scikit-learn,opencv-python-headless,Pillow,av \
|| uv pip install -r requirements.txt --index-url "$PIP_MIRROR"
else
warn "未找到 requirements.txt,跳过依赖安装"
fi
# 激活 venv 供后续自检使用
# shellcheck disable=SC1091
[ -f "$VENV_DIR/bin/activate" ] && source "$VENV_DIR/bin/activate"
log "Python: $(python --version 2>&1) 位置: $(command -v python)"
# =============================================================================
log "[6/7] 写出环境变量脚本 activate.sh"
# =============================================================================
# 常见环境坑统一在这里处理,跑项目前 source 一下即可
# 用单引号 heredoc 让脚本体保持字面(不在生成时展开),实现"自动定位所在目录",
# 这样整个项目可以搬到任意路径,activate.sh 仍能找到自己的 .venv / bin。
cat > "$PROJECT_DIR/activate.sh" <<'EOF'
#!/usr/bin/env bash
# 跑项目前先: source activate.sh —— 自动定位项目目录,项目可放任意路径
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Python:优先项目内自包含的 ./python(make_portable.sh 产物,可随便放);否则用 .venv
if [ -x "$HERE/python/bin/python3" ]; then
export PATH="$HERE/bin:$HERE/python/bin:$PATH"
else
export VIRTUAL_ENV="$HERE/.venv"
export PATH="$HERE/bin:$VIRTUAL_ENV/bin:$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
fi
# UTF-8:项目有中文文件名(数据.jsonl)。优先选系统存在的 UTF-8 locale,避免 setlocale 警告;
# 并强制 Python 用 UTF-8(即使系统没有 UTF-8 locale 也能正确处理中文文件名)。
if locale -a 2>/dev/null | grep -qiE '^C\.(utf-?8|UTF-?8)$'; then
export LANG=C.UTF-8 LC_ALL=C.UTF-8
elif locale -a 2>/dev/null | grep -qiE 'en_US\.(utf-?8|UTF-?8)'; then
export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
fi
export PYTHONUTF8=1
export PYTHONIOENCODING=utf-8
# HuggingFace 镜像(联网下模型时用)
export HF_ENDPOINT=__HF_ENDPOINT__
# 模型缓存:项目内有 ./models 就用它(自包含包),否则用默认 ~/.cache/huggingface
[ -d "$HERE/models" ] && export HF_HOME="$HERE/models"
# 只在模型【确实已缓存】时才强制离线,否则允许联网下载(避免"没模型又禁网"的死锁)
if ls "${HF_HOME:-$HOME/.cache/huggingface}"/hub/models--*faster-whisper* >/dev/null 2>&1; then
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
fi
# 若项目自带 CUDA 库(WHISPER_GPU=1 打包),加进 LD_LIBRARY_PATH,让 ctranslate2 找到(whisper GPU)
_nvlibs="$(ls -d "$HERE"/python/lib/python*/site-packages/nvidia/*/lib 2>/dev/null | tr '\n' ':')"
[ -n "$_nvlibs" ] && export LD_LIBRARY_PATH="${_nvlibs}${LD_LIBRARY_PATH:-}"
# 限制数值库线程:防止 whisper/numpy/openblas 每个都抢满核(多视频并发时互相踩)
export OMP_NUM_THREADS=2
export OPENBLAS_NUM_THREADS=2
export MKL_NUM_THREADS=2
echo "环境已激活: $(python --version 2>&1) | $HERE"
EOF
sed -i "s|__HF_ENDPOINT__|$HF_ENDPOINT_URL|" "$PROJECT_DIR/activate.sh"
chmod +x "$PROJECT_DIR/activate.sh"
# 提高文件句柄上限(高并发 + 海量小帧文件),当前会话生效;永久需改 /etc/security/limits.conf
ulimit -n 65535 2>/dev/null || warn "ulimit 提升失败(非致命),如需永久生效请改 /etc/security/limits.conf"
# =============================================================================
log "[7/7] 环境自检"
# =============================================================================
ok=1
check() { if eval "$2" >/dev/null 2>&1; then echo " ✓ $1"; else echo " ✗ $1"; ok=0; fi; }
check "gcc" "gcc --version"
check "ffmpeg" "ffmpeg -version"
check "ffprobe" "ffprobe -version"
check "ffmpeg 支持 AV1" "ffmpeg -hide_banner -decoders | grep -iE 'dav1d|av1'"
check "uv" "uv --version"
check "python" "python --version"
check "import cv2" "python -c 'import cv2'"
check "import numpy" "python -c 'import numpy'"
check "import sklearn" "python -c 'import sklearn'"
check "import av(PyAV)" "python -c 'import av'"
check "import faster_whisper" "python -c 'import faster_whisper'"
check "import qcloud_cos" "python -c 'import qcloud_cos'"
# GPU 检测(可选,有则抽帧能硬解加速)
if have nvidia-smi && nvidia-smi >/dev/null 2>&1; then
echo " ✓ 检测到 NVIDIA GPU(抽帧可硬解加速)"
else
echo " · 未检测到 GPU(抽帧用 CPU,正常)"
fi
echo
if [ "$ok" -eq 1 ]; then
log "全部就绪 ✅"
else
warn "有项目未通过(见上面 ✗),请按提示排查"
fi
cat <<EOF
下一步:
source activate.sh
python main.py --source-list ./text2 --max-workers 12
EOF

52
scripts/stop.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 停止本项目所有进程(主程序 + 进程池worker + ffmpeg子进程 + coscli)。
# 用法: bash stop.sh 普通停止
# bash stop.sh -y 不确认直接停
set -u
echo "==> 即将停止的进程:"
ps -eo pid,etime,rss,args 2>/dev/null | grep -E "main\.py|run_worker\.py|multiprocessing|forkserver|[f]fmpeg|[c]oscli" | grep -v grep | grep -v "stop.sh" || echo " (没找到运行中的进程)"
echo ""
if [ "${1:-}" != "-y" ]; then
read -r -p "确认全部杀掉? [y/N] " ans
case "$ans" in
y|Y) ;;
*) echo "已取消"; exit 0 ;;
esac
fi
echo "==> 1. 杀主程序 + 进程池worker"
pkill -9 -f "main.py" 2>/dev/null && echo " ✓ main.py"
pkill -9 -f "run_worker.py" 2>/dev/null && echo " ✓ run_worker.py"
pkill -9 -f "multiprocessing" 2>/dev/null && echo " ✓ multiprocessing worker"
pkill -9 -f "forkserver" 2>/dev/null && echo " ✓ forkserver"
echo "==> 2. 杀 ffmpeg 子进程(不会自动死, 必须单独杀, 否则占GPU/CPU)"
pkill -9 ffmpeg 2>/dev/null && echo " ✓ ffmpeg"
pkill -9 ffprobe 2>/dev/null && echo " ✓ ffprobe"
echo "==> 3. 杀残留 coscli(正在传输的)"
pkill -9 -f "coscli" 2>/dev/null && echo " ✓ coscli"
sleep 1
echo ""
echo "==> 检查残留:"
LEFT=$(ps -eo pid,args 2>/dev/null | grep -E "main\.py|run_worker\.py|multiprocessing|forkserver|[f]fmpeg|[c]oscli" | grep -v grep | grep -v "stop.sh")
if [ -z "$LEFT" ]; then
echo " ✓ 全部已停止"
else
echo " ⚠️ 仍有残留, 再杀一次:"
echo "$LEFT"
echo "$LEFT" | awk '{print $1}' | xargs -r kill -9 2>/dev/null
echo " 已强制杀掉"
fi
# 可选: 看GPU是否释放
if command -v nvidia-smi >/dev/null 2>&1; then
echo ""
echo "==> GPU 占用(确认ffmpeg/whisper已释放):"
nvidia-smi --query-gpu=index,memory.used,utilization.gpu --format=csv,noheader 2>/dev/null
fi
echo ""
echo "停止完成。重启: bash scripts/启动.sh 30"

164
scripts/启动.sh Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env bash
# =============================================================================
# 一键启动(防 SIGHUP/外部kill 脱离终端 + 调好参数 + 启动前自检 + 防重复启动)
#
# 两种模式(MODE 环境变量切换):
# MODE=worker (默认) —— 常驻 worker, 从 COS 自动认领任务(run_worker.py)。不需要本地 text2。
# MODE=local —— 旧模式, 处理本地 ./text2 下的任务txt(main.py)。
#
# 用法:
# bash scripts/启动.sh # 默认 worker 模式启动
# bash scripts/启动.sh 32 # 临时把 max-workers 改成 32
# MODE=local bash scripts/启动.sh # 用旧的本地 text2 模式
# MAX_WORKERS=20 MEM_GUARD=40 bash scripts/启动.sh
#
# 停止: bash scripts/stop.sh -y
# =============================================================================
set -u
cd "$(cd "$(dirname "$0")/.." && pwd)"
ROOT="$(pwd)"
# ---- 可调参数(命令行第1个参数 / 环境变量 均可覆盖) ----
# MODE 未显式指定时, 读 config.dispatch.source 决定: local -> 本地text2, 其余 -> COS常驻
if [ -z "${MODE:-}" ]; then
_src="$(python -c "from config.config_utils import config; print((config.get_dispatch_config() or {}).get('source','cos'))" 2>/dev/null || echo cos)"
[ "$_src" = "local" ] && MODE="local" || MODE="worker"
fi
MODE="${MODE:-worker}" # worker=COS认领常驻 / local=本地text2
MAX_WORKERS="${1:-${MAX_WORKERS:-25}}" # 0=不传给python,由main.py/run_worker.py自动按本机CPU核数选
# 共享机/跟OCR服务同机部署的, 建议显式传小一点的值(如24), 别用自动
MAX_TASKS="${MAX_TASKS:-0}" # 0=不回收worker(避免3.11回收缩水把并发缩到0)
MEM_GUARD="${MEM_GUARD:-30}" # 可用内存低于此(G)暂停启动新视频
SOURCE_LIST="${SOURCE_LIST:-./text2}" # 仅 local 模式: 任务txt目录
EXECUTOR="${EXECUTOR:-process}" # process=进程池(绕GIL) / thread
LOG="${LOG:-app.log}"
NICE="${NICE:-15}" # 处理进程CPU优先级(0~19,越大越低)
LOG_MAX_MB="${LOG_MAX_MB:-200}" # $LOG 超过这个大小(MB)就在启动前轮转一次, 0=不轮转
# ---- 启动前轮转一次日志(常驻进程一跑数天/数周, >>追加写不会自己变小) ----
_rotate_log() {
local f="$1" maxmb="$2"
[ "$maxmb" -le 0 ] && return 0
[ -f "$f" ] || return 0
local sz_mb=$(( $(stat -c%s "$f" 2>/dev/null || echo 0) / 1024 / 1024 ))
if [ "$sz_mb" -ge "$maxmb" ]; then
local ts; ts="$(date +%Y%m%d_%H%M%S)"
mv "$f" "${f}.${ts}" 2>/dev/null && echo "[日志轮转] $f (${sz_mb}M) → ${f}.${ts}, 重新开始写"
ls -t "${f}".[0-9]* 2>/dev/null | tail -n +6 | xargs -r rm -f # 只留最近5份轮转备份
fi
}
_rotate_log "$LOG" "$LOG_MAX_MB"
# 按模式确定入口与进程匹配模式
if [ "$MODE" = "worker" ]; then
ENTRY="run_worker.py"
PROC_PAT='python .*run_worker\.py'
else
ENTRY="main.py"
PROC_PAT='python .*main\.py'
fi
MAX_WORKERS_DISP="$MAX_WORKERS"
[ "$MAX_WORKERS" = "0" ] && MAX_WORKERS_DISP="0(自动按CPU核数选, 实际值见下面python启动日志)"
echo "=================================================="
echo " 启动 $ENTRY (MODE=$MODE)"
echo " max-workers=$MAX_WORKERS_DISP max-tasks-per-child=$MAX_TASKS mem-guard=${MEM_GUARD}G"
echo " executor=$EXECUTOR nice=$NICE(让OCR服务优先) 日志→$LOG"
[ "$MODE" = "local" ] && echo " source-list=$SOURCE_LIST"
echo "=================================================="
# ---- 1. 防重复启动 ----
if pgrep -af "$PROC_PAT" 2>/dev/null | grep -v 'multiprocessing' | grep -v 'grep' >/dev/null; then
echo "✗ 已经有 $ENTRY 在运行了, 不重复启动。"
echo " 查看: pgrep -af '$PROC_PAT' | grep -v multiprocessing"
echo " 如要重启: 先 bash scripts/stop.sh -y"
exit 1
fi
# ---- 2. 启动前自检 ----
fail=0
need() { command -v "$1" >/dev/null 2>&1 && echo " ✓ $1" || { echo " ✗ 缺少 $1 (需在 PATH 或项目目录)"; fail=1; }; }
echo "[依赖检查]"
need python
need ffmpeg
need ffprobe
if command -v coscli >/dev/null 2>&1; then echo " ✓ coscli"
elif [ -x "./coscli-linux-amd64" ]; then echo " ✓ ./coscli-linux-amd64"
else echo " ✗ 找不到 coscli / ./coscli-linux-amd64"; fail=1; fi
echo "[配置/任务检查]"
[ -f config/config.json ] && echo " ✓ config/config.json" || { echo " ✗ 缺 config/config.json"; fail=1; }
if [ "$MODE" = "worker" ]; then
# worker 模式: 任务来自 COS, 不需要本地 text2; 检查 dispatch 配置与机器ID + COS 队列可达
chk=$(python - <<'PY' 2>&1
import sys
try:
from config.config_utils import config
from tool import job_cos
dp = config.get_dispatch_config() or {}
if not dp.get("jobs_root"):
print("NO_JOBS_ROOT"); sys.exit(0)
mid = job_cos.get_machine_id()
todo = job_cos.list_todo() # 顺带验证 COS 队列可达
print("OK\t%s\t%d" % (mid, len(todo)))
except Exception as e:
print("ERR\t%s" % e)
PY
)
case "$chk" in
OK*) mid=$(echo "$chk" | cut -f2); n=$(echo "$chk" | cut -f3)
echo " ✓ dispatch 配置OK 机器ID=$mid todo剩余分片=$n"
[ "$n" = "0" ] && echo " ⚠ todo 队列为空(worker会空闲等待; 先 python tools/seed_jobs.py 灌任务)";;
NO_JOBS_ROOT) echo " ✗ config.dispatch.jobs_root 未配置(取任务地址)"; fail=1;;
*) echo " ✗ 访问 COS 任务队列失败: ${chk#ERR }"; fail=1;;
esac
else
if ls "$SOURCE_LIST"/*.txt >/dev/null 2>&1; then
n=$(ls "$SOURCE_LIST"/*.txt 2>/dev/null | wc -l)
echo " ✓ $SOURCE_LIST 有 $n 个任务txt"
else
echo " ✗ $SOURCE_LIST 下没有 .txt 任务文件"; fail=1
fi
fi
if [ "$fail" -ne 0 ]; then
echo ""
echo "✗ 自检未通过, 已中止(没启动)。修好上面 ✗ 的项再跑。"
exit 1
fi
# ---- 3. 启动(setsid+nohup+disown 三保险, SSH断开也杀不到) ----
echo ""
echo "[启动中...]"
if [ "$MODE" = "worker" ]; then
setsid nohup nice -n "$NICE" python run_worker.py \
--max-workers "$MAX_WORKERS" \
--max-tasks-per-child "$MAX_TASKS" \
--mem-guard-gb "$MEM_GUARD" \
--executor "$EXECUTOR" >> "$LOG" 2>&1 &
else
setsid nohup nice -n "$NICE" python main.py \
--source-list "$SOURCE_LIST" \
--max-workers "$MAX_WORKERS" \
--max-tasks-per-child "$MAX_TASKS" \
--mem-guard-gb "$MEM_GUARD" \
--executor "$EXECUTOR" >> "$LOG" 2>&1 &
fi
disown 2>/dev/null || true
# ---- 4. 等几秒确认起来了 ----
sleep 6
if pgrep -af "$PROC_PAT" 2>/dev/null | grep -v 'multiprocessing' | grep -v 'grep' >/dev/null; then
echo "✓ 已启动。日志末尾:"
tail -n 5 "$LOG" 2>/dev/null | sed 's/^/ /'
echo ""
echo " 实时看日志: tail -f $LOG"
echo " 数worker: pgrep -af multiprocessing | wc -l (稍等会接近 $MAX_WORKERS_DISP)"
echo " 停止: bash scripts/stop.sh -y"
else
echo "✗ 启动后没检测到进程, 查日志看原因:"
tail -n 20 "$LOG" 2>/dev/null | sed 's/^/ /'
exit 1
fi

121
scripts/守护重启.sh Normal file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# =============================================================================
# 守护重启: main.py 被外部 kill / 异常退出后, 自动拉起(共享机上挡不住别人kill, 就让它自己回来)。
# 【带明确开关, 想关随时关, 不会和你抢】:
# 关闭原理 = 放一个"暂停标记文件" .guard_paused, 守护看到它就什么都不做。
#
# 用法:
# bash scripts/守护重启.sh install # 安装(每分钟检测一次, 没在跑就启动)
# bash scripts/守护重启.sh off # ★想停就用这个★ 暂停自动重启 + 停掉当前进程
# bash scripts/守护重启.sh on # 重新开启自动重启(下一分钟内会被拉起)
# bash scripts/守护重启.sh status # 看状态(装没装/开没开/在没在跑)
# bash scripts/守护重启.sh uninstall # 彻底卸载(删掉定时任务, 进程不动)
# bash scripts/守护重启.sh tick # (cron 内部调用, 别手动跑)
#
# 启动参数想改, 改下面 START= 这一行即可。
# =============================================================================
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
PAUSE="$ROOT/.guard_paused" # 这个文件存在 = 暂停自动重启
LOCK="$ROOT/.guard.lock"
GLOG="$ROOT/guard.log"
CRON_TAG="# ocr-guard" # crontab 里的标识, 用于增删去重
# ★ 启动命令(按之前调优建议; 想改并发/参数改这里) ★
# MODE=worker(默认): 常驻 worker 从 COS 认领任务; MODE=local: 旧的本地 text2 模式。
# nice -n 15: 让 co-located 的 OCR 服务优先抢 CPU; max-tasks-per-child 20: 本机内存偏紧
# (44G可用/184G总量), 定期回收worker释放常驻内存; 3.11回收有缩水风险(见CLAUDE.md), 但20这个
# 量级配合 --mem-guard-gb 观察下来风险可控, 内存不紧的机器可以改回0(不回收)。
if [ -z "${MODE:-}" ]; then
_src="$(python -c "from config.config_utils import config; print((config.get_dispatch_config() or {}).get('source','cos'))" 2>/dev/null || echo cos)"
[ "$_src" = "local" ] && MODE="local" || MODE="worker"
fi
MODE="${MODE:-worker}"
if [ "$MODE" = "worker" ]; then
START='nice -n 15 python run_worker.py --max-workers 30 --max-tasks-per-child 20 --mem-guard-gb 30 --executor process'
PROC_PAT='python .*run_worker\.py'
else
START='nice -n 15 python main.py --source-list ./text2 --max-workers 30 --max-tasks-per-child 20 --mem-guard-gb 30 --executor process'
PROC_PAT='python .*main\.py'
fi
# 判断父进程是否在跑(forkserver worker 命令行不含入口文件名, 不会误判)
running() {
pgrep -af "$PROC_PAT" 2>/dev/null | grep -v 'multiprocessing' | grep -v 'grep' >/dev/null 2>&1
}
# app.log 轮转: 进程可能正在往它里面 >> 追加写, 不能直接 mv(会导致进程还在写旧inode, 新文件
# 看起来永远是空的) —— 用 copytruncate: 先 cp 一份出去, 再原地截断; O_APPEND 的fd是按"当前
# 文件大小"找写入位置的, 原地截断后进程会自动从offset 0继续写这个(同一个)文件, 不受影响。
_rotate_applog() {
local f="$ROOT/app.log" maxmb=200
[ -f "$f" ] || return 0
local sz_mb=$(( $(stat -c%s "$f" 2>/dev/null || echo 0) / 1024 / 1024 ))
if [ "$sz_mb" -ge "$maxmb" ]; then
local ts; ts="$(date +%Y%m%d_%H%M%S)"
cp "$f" "${f}.${ts}" 2>/dev/null && : > "$f" \
&& echo "$(date '+%F %T') [日志轮转] app.log(${sz_mb}M) → app.log.${ts}, 原地清空续写" >> "$GLOG"
ls -t "${f}".[0-9]* 2>/dev/null | tail -n +6 | xargs -r rm -f # 只留最近5份
fi
}
case "${1:-status}" in
install)
( crontab -l 2>/dev/null | grep -vF "$CRON_TAG"
echo "* * * * * cd $ROOT && bash scripts/守护重启.sh tick $CRON_TAG" ) | crontab -
rm -f "$PAUSE"
echo "✓ 守护已安装(每分钟检测), 自动重启: 开启"
echo " 想停: bash scripts/守护重启.sh off"
;;
uninstall)
crontab -l 2>/dev/null | grep -vF "$CRON_TAG" | crontab -
echo "✓ 已卸载守护定时任务(不再自动重启)。当前进程未动。"
echo " 如要同时停进程: bash scripts/stop.sh -y"
;;
off)
touch "$PAUSE"
echo "✓ 已暂停自动重启(标记 $PAUSE)。正在停止当前进程..."
bash "$ROOT/scripts/stop.sh" -y
echo "✓ 已停止, 且守护不会再把它拉起。恢复运行: bash scripts/守护重启.sh on"
;;
on)
rm -f "$PAUSE"
echo "✓ 自动重启已开启。若当前没在跑, 一分钟内会被拉起。"
;;
tick)
# 暂停中 → 什么都不做(这就是 off 之所以能稳关掉的原因)
[ -f "$PAUSE" ] && exit 0
# 防并发: 同一时刻只允许一个 tick 启动
exec 9>"$LOCK"
flock -n 9 || exit 0
_rotate_applog # 每分钟顺带检查一次, 跟要不要重启无关(常驻不重启时全靠这个兜底)
running && exit 0
echo "$(date '+%F %T') 检测到未运行 → 启动" >> "$GLOG"
setsid nohup $START >> "$ROOT/app.log" 2>&1 &
;;
status)
echo "项目目录: $ROOT"
if crontab -l 2>/dev/null | grep -qF "$CRON_TAG"; then
echo "守护定时任务: ✓ 已安装(每分钟检测)"
else
echo "守护定时任务: ✗ 未安装"
fi
if [ -f "$PAUSE" ]; then
echo "自动重启: ⏸ 已暂停 (.guard_paused 存在 → 不会拉起)"
else
echo "自动重启: ▶ 开启"
fi
if running; then echo "main.py: 运行中"; else echo "main.py: 未运行"; fi
;;
*)
echo "用法: install | on | off | uninstall | status | tick"
;;
esac