Compare commits
36 Commits
hdmi
...
ffmpegAudi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587c0e10b4 | ||
|
|
77969c85fe | ||
|
|
f0b1f9cf47 | ||
|
|
219a8cef61 | ||
|
|
fd3d952ab8 | ||
|
|
ab6a6cac65 | ||
|
|
032c75fb6f | ||
|
|
950cb0ae4b | ||
|
|
9700404418 | ||
|
|
1da3383735 | ||
|
|
b850d1a8bc | ||
|
|
b6c5185a59 | ||
|
|
4030a4dc28 | ||
|
|
00bcaee9fa | ||
|
|
54daccea6b | ||
|
|
4eac033e93 | ||
|
|
17900701f1 | ||
|
|
033b76bda5 | ||
|
|
bf6b827e1f | ||
|
|
b4183ed85b | ||
|
|
f0823c7593 | ||
|
|
326ab8aea2 | ||
|
|
3842833ad0 | ||
|
|
9e589e6263 | ||
|
|
7090f4481a | ||
|
|
eafcc2b7d0 | ||
|
|
e510e66c13 | ||
|
|
d86e8e339d | ||
|
|
ba4aaa1b65 | ||
|
|
475e15bc16 | ||
|
|
31e77d5203 | ||
|
|
6c383ee489 | ||
|
|
9d3ec4fa2c | ||
|
|
7402773a3e | ||
|
|
140790c4a4 | ||
|
|
1bdf41ac3f |
@@ -1,15 +1,10 @@
|
||||
https://live.douyin.com/372551154040,主播: 向嘉_手打柠檬茶
|
||||
https://live.douyin.com/203476627795,主播: 蜜桃姐姐(手打柠檬茶)
|
||||
https://live.douyin.com/174360482665,主播: 冰块_(体力的神是我大姐)
|
||||
https://live.douyin.com/651493903077,主播: 茶聚手打柠檬茶(小王同学)
|
||||
https://live.douyin.com/503988877962,主播: 涛涛茶档
|
||||
https://live.douyin.com/664793904394,主播: 摆摊打柠檬
|
||||
https://live.douyin.com/162450932525,主播: 曾漂亮•手打柠檬茶
|
||||
https://live.douyin.com/182011399880,主播: 心怡(手打柠檬茶)
|
||||
https://live.douyin.com/473695498831,主播: 知心(手打柠檬茶)
|
||||
https://live.douyin.com/210443559964,主播: 小小诺
|
||||
https://live.douyin.com/21146786242,主播: 胡辣汤早餐店
|
||||
https://live.douyin.com/369626255042,主播: 六六的摆摊日记
|
||||
https://live.douyin.com/41148117948,主播: 煎饼老杜
|
||||
https://live.douyin.com/109578500864,主播: 三妹凉皮
|
||||
https://live.douyin.com/35932729226,主播: 内向小学生
|
||||
https://live.douyin.com/629587976874,主播: 伟利火烧
|
||||
https://live.douyin.com/606957569518,主播: 李泽宇(煎饼商)
|
||||
https://live.douyin.com/288000519466,主播: 五花肉肉
|
||||
https://live.douyin.com/69064125466,主播: 张大丽丽的丽茶努力版
|
||||
https://live.douyin.com/838061972379,主播: 番茄妹妹
|
||||
https://live.douyin.com/562629369787,主播: 叹茶柠檬茶
|
||||
https://live.douyin.com/565208465584,主播: 财圆圆
|
||||
https://live.douyin.com/51142028798,主播: 小夏妹妹
|
||||
https://live.douyin.com/974451428277,主播: 雅如柠檬茶
|
||||
https://live.douyin.com/334176667239,主播: 樱桃炒饭(摆摊版)
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
[youtube]
|
||||
# 只写 stream key(推荐方式)
|
||||
key = ue78-1c3e-mr9g-14mz-9r4z
|
||||
# 必填:YouTube 直播推流 key(在 YouTube 工作室 → 创建 → 流式传输 获取)
|
||||
key = qxvb-r47b-r5ju-6ud3-6k7z
|
||||
; key = x04z-564w-aks7-embw-30y4
|
||||
|
||||
# 可选:是否使用 RTMPS 加密推流,填 否 则用 RTMP(默认 RTMPS)
|
||||
; rtmps = 否
|
||||
|
||||
# 可选:视频比特率 kbps,YouTube 推荐 2500,可填 3000-4500 提高画质
|
||||
; bitrate = 2500
|
||||
|
||||
# 可选:软编 speed 长期小于 1 时开启——轻量音频链(无 arnndn/长 EQ),减轻 CPU。填 1 或 是
|
||||
fast_audio = 1
|
||||
119
docs/REALTIME_AUDIO_DEMUCS.md
Normal file
119
docs/REALTIME_AUDIO_DEMUCS.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 实时音频处理最佳方案(Demucs + RTX 3060)
|
||||
|
||||
## 一、方案对比
|
||||
|
||||
| 方案 | 版权规避率 | 延迟 | 算力 | 复杂度 |
|
||||
|------|------------|------|------|--------|
|
||||
| **FFmpeg 纯滤波**(当前) | 60-70% | 0 | 低 | 低 |
|
||||
| **Demucs 人声分离** | 90%+ | 4-8s | RTX 3060 | 中高 |
|
||||
|
||||
---
|
||||
|
||||
## 二、推荐架构:Demucs 实时管道
|
||||
|
||||
```
|
||||
抖音流 ─┬─ FFmpeg 解复用 ─┬─ 视频 ─────────────────────────────┐
|
||||
│ │ │
|
||||
│ └─ 音频 ─→ Python Demucs ─→ 人声 ────┼─→ FFmpeg 复用 ─→ YouTube RTMP
|
||||
│ (分块处理) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 核心参数(RTX 3060 12GB)
|
||||
|
||||
| 参数 | 推荐值 | 说明 |
|
||||
|------|--------|------|
|
||||
| **模型** | `htdemucs`(单模型,非 _ft) | 速度最快,质量够用 |
|
||||
| **chunk 长度** | 2.5-3 秒 | 延迟 ≈ 2×chunk ≈ 5-6s |
|
||||
| **overlap** | 0.25 | 减少接缝,质量更好 |
|
||||
| **shifts** | 0 | 降低算力,实时必需 |
|
||||
| **segment** | 7.8(默认)或更小 | 显存不足时减小 |
|
||||
|
||||
### RTX 3060 算力
|
||||
|
||||
- 约 13 TFLOPS,高于 Demucs 实时所需 2 TFLOPS
|
||||
- 12GB 显存足够 htdemucs(约 6-8GB)
|
||||
- 预期:**2-4× 实时速度**,可满足直播
|
||||
|
||||
---
|
||||
|
||||
## 三、实现方式
|
||||
|
||||
### 方式 A:Python 自建管道(推荐)
|
||||
|
||||
```python
|
||||
# 伪代码流程
|
||||
# 1. FFmpeg 从抖音拉流,输出: -vn -acodec pcm_s16le -ar 44100 -ac 1 pipe:1
|
||||
# 2. 主进程读取音频块(2.5s = 110250 samples @ 44.1kHz)
|
||||
# 3. 调用 demucs.separate_audio(model, chunk) 得到 vocals
|
||||
# 4. 人声写入 pipe,FFmpeg 另一进程读取并与视频复用推流
|
||||
```
|
||||
|
||||
**依赖**:
|
||||
```
|
||||
demucs
|
||||
torch
|
||||
torchaudio
|
||||
```
|
||||
|
||||
**模型加载**:
|
||||
```python
|
||||
from demucs.pretrained import get_model
|
||||
model = get_model('htdemucs') # 单模型,非 htdemucs_ft
|
||||
```
|
||||
|
||||
### 方式 B:GStreamer(若可用)
|
||||
|
||||
GStreamer 1.28+ 内置 demucs 元素,CPU 约 8× 实时:
|
||||
|
||||
```bash
|
||||
# 需安装 gst-plugins-rs (含 demucs)
|
||||
gst-launch-1.0 uridecodebin uri=抖音流 ! audioconvert ! demucs ! ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、分块处理要点
|
||||
|
||||
1. **重叠**:chunk 之间 25% 重叠,避免接缝
|
||||
2. **队列**:双缓冲,一块处理时另一块接收
|
||||
3. **采样率**:Demucs 默认 44100 Hz,与 FFmpeg 输出一致
|
||||
4. **延迟**:总延迟 ≈ 2 × chunk 长度(约 5-6 秒可接受)
|
||||
|
||||
---
|
||||
|
||||
## 五、快速验证(离线测试)
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
pip install demucs torch torchaudio
|
||||
|
||||
# 测试 30 秒音频分离速度
|
||||
demucs -n htdemucs --two-stems=vocals -j 1 test_30s.mp3 -o output/
|
||||
|
||||
# 若 30s 在 10s 内完成 → 可实时
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、与当前方案的关系
|
||||
|
||||
- **当前**:`douyin_youtube_ffplay.py` 纯 FFmpeg,60-70% 规避率
|
||||
- **升级**:可新增 `douyin_youtube_demucs.py`,集成 Demucs 管道
|
||||
- **切换**:通过 config 选择 `mode = ffmpeg` 或 `mode = demucs`
|
||||
|
||||
---
|
||||
|
||||
## 七、配置建议(config/youtube.ini)
|
||||
|
||||
```ini
|
||||
[youtube]
|
||||
key = xxx
|
||||
|
||||
# 音频模式: ffmpeg | demucs
|
||||
; audio_mode = ffmpeg
|
||||
|
||||
# Demucs 专用
|
||||
; demucs_chunk = 2.5
|
||||
; demucs_model = htdemucs
|
||||
```
|
||||
50
docs/YOUTUBE_LIVE_SEO.md
Normal file
50
docs/YOUTUBE_LIVE_SEO.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# YouTube Live 转播优化与 SEO 指南
|
||||
|
||||
## 一、推流技术参数(已自动优化)
|
||||
|
||||
- **分辨率**:720p 竖屏 (720×1280)
|
||||
- **比特率**:4.5 Mbps(可在 config/youtube.ini 的 bitrate 调整)
|
||||
- **关键帧**:2 秒(符合 YouTube 推荐)
|
||||
- **音频**:AAC 128kbps 48kHz 立体声
|
||||
- **协议**:RTMPS 加密推流(推荐)
|
||||
- **防版权**:开源音频处理链(降噪 + 带通 + EQ + 相位扰动)
|
||||
|
||||
## 二、YouTube 直播 SEO 与推荐权重
|
||||
|
||||
直播标题、描述、标签在 **YouTube 工作室** 创建直播时设置,推流程序无法修改。以下为提升推荐权重的建议:
|
||||
|
||||
### 标题(前 60 字最关键)
|
||||
|
||||
- 主关键词放前面,如:`LIVE 摆摊日常 | 路边直播`
|
||||
- 可加实时感词汇:LIVE、直播、实时、正在直播
|
||||
- 避免堆砌关键词,保持可读性
|
||||
|
||||
### 描述(前 125 字影响搜索)
|
||||
|
||||
- 首段包含主关键词和内容概要
|
||||
- 可说明:转播自抖音、直播内容类型、时间等
|
||||
|
||||
### 标签
|
||||
|
||||
- 5–15 个相关标签,如:live stream, 摆摊, 路边直播, 抖音转播
|
||||
- 与标题、描述语义一致
|
||||
|
||||
### 算法相关因素
|
||||
|
||||
- **观看时长**:用户停留越久,推荐越高
|
||||
- **互动**:点赞、评论、分享
|
||||
- **画质与音质**:清晰稳定的流有助于留存
|
||||
|
||||
## 三、config/youtube.ini 配置说明
|
||||
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| key | 必填,YouTube stream key | key = xxxx-xxxx-xxxx |
|
||||
| rtmps | 是否用 RTMPS(默认是) | rtmps = 否 |
|
||||
| bitrate | 视频比特率 kbps | bitrate = 4500 |
|
||||
|
||||
## 四、稳定性建议
|
||||
|
||||
1. 网络:上行带宽 ≥ 6 Mbps,建议有线
|
||||
2. 推流前在 YouTube 工作室完成标题、描述、标签设置
|
||||
3. 定期检查 config/youtube.ini 中的 stream key 是否有效
|
||||
@@ -79,25 +79,42 @@ def start_douyin_youtube_ffplay(
|
||||
只从 config/youtube.ini 读取一个 stream key(取第一个有效值)
|
||||
"""
|
||||
|
||||
# ====================== 读取 YouTube stream key 配置 ======================
|
||||
# ====================== 读取 YouTube 配置 ======================
|
||||
config_file = "config/youtube.ini"
|
||||
rtmps_base = "rtmps://a.rtmp.youtube.com/live2/"
|
||||
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
|
||||
default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
|
||||
youtube_rtmp = rtmp_base + default_key # 默认值
|
||||
use_rtmps = True
|
||||
# 720p 竖屏 YouTube 建议 4–6 Mbps,偏低易触发「数据不足/卡顿」提示
|
||||
b_v, maxrate, bufsize = "4000k", "4500k", "8000k"
|
||||
stream_key = None
|
||||
fast_audio = False # config: fast_audio=1 可跳过 arnndn+长 EQ,显著减轻 CPU
|
||||
|
||||
if os.path.exists(config_file):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_file, encoding="utf-8")
|
||||
if config.has_section("youtube"):
|
||||
for key_name, value in config.items("youtube"):
|
||||
value = value.strip()
|
||||
if value:
|
||||
if value.startswith("rtmp://"):
|
||||
youtube_rtmp = value
|
||||
else:
|
||||
youtube_rtmp = rtmp_base + value
|
||||
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
||||
break # 只取第一个有效 key,保持单路推流(不加多路逻辑)
|
||||
for k, v in config.items("youtube"):
|
||||
v = (v or "").strip()
|
||||
if k == "rtmps" and v.lower() in ("0", "false", "否"):
|
||||
use_rtmps = False
|
||||
elif k == "fast_audio" and v.lower() in ("1", "true", "yes", "是", "开", "on"):
|
||||
fast_audio = True
|
||||
elif k == "bitrate" and v:
|
||||
try:
|
||||
n = int(v)
|
||||
b_v, maxrate, bufsize = f"{n}k", f"{min(n * 12 // 10, 8000)}k", f"{n * 2}k"
|
||||
except ValueError:
|
||||
pass
|
||||
elif (k == "key" or k == "stream_key") and v and not v.startswith("#"):
|
||||
stream_key = v
|
||||
if stream_key:
|
||||
if stream_key.startswith("rtmp://") or stream_key.startswith("rtmps://"):
|
||||
youtube_rtmp = stream_key
|
||||
else:
|
||||
youtube_rtmp = (rtmps_base if use_rtmps else rtmp_base) + stream_key
|
||||
print(f"[INFO] 从 {config_file} 读取 YouTube 推流地址")
|
||||
else:
|
||||
raise ValueError(f"请在 {config_file} 中配置 key=你的stream_key")
|
||||
|
||||
# ====================== 常量配置 ======================
|
||||
MAX_RETRY_DELAY = 90
|
||||
@@ -106,6 +123,7 @@ def start_douyin_youtube_ffplay(
|
||||
NO_FRAME_TIMEOUT = 120
|
||||
START_CHECK_AFTER = 25
|
||||
STALE_OUTPUT_SECONDS = 40
|
||||
RECONNECT_DELAY_MAX = 10
|
||||
|
||||
END_KEYWORDS = [
|
||||
"connection reset by peer",
|
||||
@@ -113,6 +131,7 @@ def start_douyin_youtube_ffplay(
|
||||
"invalid data found",
|
||||
"server returned 403",
|
||||
"server returned 404",
|
||||
"server returned 500",
|
||||
"ingestion error",
|
||||
"access denied",
|
||||
"packet too short",
|
||||
@@ -120,6 +139,10 @@ def start_douyin_youtube_ffplay(
|
||||
"live has ended",
|
||||
"no route to host",
|
||||
"connection timed out",
|
||||
"connection refused",
|
||||
"error writing output",
|
||||
"broken pipe",
|
||||
"i/o error",
|
||||
]
|
||||
|
||||
douyin_headers = (
|
||||
@@ -134,10 +157,11 @@ def start_douyin_youtube_ffplay(
|
||||
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ====================== NVENC 检测 ======================
|
||||
# ====================== NVENC 检测(符合 YouTube 推荐比特率)======================
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
# ultrafast+zerolatency:在软编下尽量让 speed≥1.0;画质略降、直播可接受
|
||||
video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0",
|
||||
"-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
|
||||
@@ -145,169 +169,90 @@ def start_douyin_youtube_ffplay(
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||
if "h264_nvenc" in (enc.stdout or ""):
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
|
||||
video_args = ["-profile:v", "high", "-level", "4.0",
|
||||
"-tune", "ll", "-rc", "cbr", "-b:v", b_v,
|
||||
"-maxrate", maxrate, "-bufsize", bufsize]
|
||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except Exception as e:
|
||||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||||
|
||||
# 720p (最原始)
|
||||
# ffmpeg_command = [
|
||||
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
# "-stats", "-stats_period", "1",
|
||||
# "-rw_timeout", "30000000",
|
||||
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
# "-reconnect_delay_max", "5",
|
||||
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||
# "-flags", "low_delay",
|
||||
# "-err_detect", "ignore_err",
|
||||
# "-max_delay", "80000",
|
||||
# "-thread_queue_size", "2048",
|
||||
# ====================== 开源音频处理:背景音乐弱化 + 防版权 ======================
|
||||
_script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_ffmpeg_cwd = None
|
||||
if fast_audio:
|
||||
print("[INFO] fast_audio=开:轻量音频链(无 arnndn/长 EQ),减轻 CPU 以利 speed≥1.0")
|
||||
_af_chain = "afftdn=nf=-26:tn=1,aresample=async=1:first_pts=0"
|
||||
else:
|
||||
# 优先 RNNoise(arnndn),无模型则用 afftdn;aphaser 相位扰动可干扰 Content ID 指纹
|
||||
_arnndn_paths = [
|
||||
os.path.join(_script_dir, "arnndn-models", "cb.rnnn"),
|
||||
os.path.join(os.path.dirname(_script_dir), "arnndn-models", "cb.rnnn"),
|
||||
"arnndn-models/cb.rnnn",
|
||||
]
|
||||
_arnndn_model = None
|
||||
for _p in _arnndn_paths:
|
||||
if os.path.isfile(_p):
|
||||
_arnndn_model = os.path.normpath(_p)
|
||||
break
|
||||
if _arnndn_model:
|
||||
print(f"[INFO] 使用 RNNoise 降噪模型: {_arnndn_model}")
|
||||
_arnndn_base = os.path.dirname(os.path.dirname(os.path.abspath(_arnndn_model)))
|
||||
_af_denoise = "arnndn=m=arnndn-models/cb.rnnn:mix=0.82,"
|
||||
_ffmpeg_cwd = _arnndn_base
|
||||
else:
|
||||
print("[INFO] 未找到 arnndn 模型,使用 afftdn 降噪(可克隆 richardpl/arnndn-models 到 arnndn-models/)")
|
||||
_af_denoise = "afftdn=nf=-26:tn=1,"
|
||||
|
||||
# "-headers", douyin_headers,
|
||||
# "-i", real_url,
|
||||
# # 720p
|
||||
# "-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
# "-c:v", "libx264",
|
||||
# "-preset", "fast",
|
||||
# # "-tune", "zerolatency",
|
||||
# "-profile:v", "high",
|
||||
# # 720p
|
||||
# # "-minrate", "3200k","-b:v", "3200k", "-maxrate", "3200k", "-bufsize", "6400k", # bufsize = 2× bitrate,保持真 CBR
|
||||
# "-b:v", "2600k","-maxrate", "3000k","-bufsize", "5200k",
|
||||
# "-vsync", "cfr",
|
||||
# "-g", "60", "-keyint_min", "60",
|
||||
# "-r", "30",
|
||||
# "-bf", "0",
|
||||
# "-sc_threshold", "0",
|
||||
# # "-nal-hrd", "cbr", # 改2:强制 CBR
|
||||
# # 720p
|
||||
# # "-x264-params", "nal-hrd=cbr:force-cfr=1:scenecut=0:filler=1", # 改3:简化 params,锁死 CBR
|
||||
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||
# "-pix_fmt", "yuv420p",
|
||||
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改4:1080p可音频上 128k(YouTube 推荐)
|
||||
# "-af", "aresample=async=1:first_pts=0",
|
||||
# 强化版:收紧带通 + 频率偏移 + 音高微调 + 多段EQ + 相位扰动(纯 FFmpeg 理论上限 ~70%)
|
||||
_af_chain = (
|
||||
_af_denoise
|
||||
+ "highpass=f=200,lowpass=f=3800,"
|
||||
+ "equalizer=f=150:width_type=o:width=2.5:g=-14,"
|
||||
+ "equalizer=f=350:width_type=o:width=2:g=-10,"
|
||||
+ "equalizer=f=600:width_type=o:width=1.8:g=-8,"
|
||||
+ "equalizer=f=1000:width_type=o:width=1.5:g=-6,"
|
||||
+ "equalizer=f=2500:width_type=o:width=1.2:g=-4,"
|
||||
+ "acompressor=threshold=-26dB:ratio=5:attack=8:release=80:makeup=5,"
|
||||
+ "afreqshift=shift=22,"
|
||||
+ "asetrate=48000*1.035,aresample=48000,"
|
||||
+ "aphaser=type=t:decay=0.4:delay=3:speed=0.7,"
|
||||
+ "volume=1.12,"
|
||||
+ "aresample=async=1:first_pts=0"
|
||||
)
|
||||
|
||||
# "-flags", "+global_header",
|
||||
# "-flvflags", "no_duration_filesize",
|
||||
# "-f", "flv", youtube_rtmp
|
||||
# ]
|
||||
|
||||
|
||||
|
||||
|
||||
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," # 人声增强
|
||||
# 720p-低CPU + 背景音乐处理 + 防ContentID
|
||||
# 720p 竖屏,符合 YouTube 推荐:4–6 Mbps、2s 关键帧、AAC 128k
|
||||
_video_mid = ["-vsync", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
|
||||
"-bf", "0", "-sc_threshold", "0"]
|
||||
if codec_v == "libx264":
|
||||
_video_mid += ["-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0"]
|
||||
# -re:按源时间戳节奏拉流,避免突发过快;不用 realtime/arealtime(输入抖动时易让 YouTube 判为数据不足)
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin",
|
||||
"-stats", "-stats_period", "1",
|
||||
"-rw_timeout", "30000000",
|
||||
"-rw_timeout", "50000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||
"-flags", "low_delay",
|
||||
"-reconnect_delay_max", str(RECONNECT_DELAY_MAX),
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-err_detect", "ignore_err",
|
||||
"-max_delay", "80000",
|
||||
"-max_delay", "500000",
|
||||
"-thread_queue_size", "2048",
|
||||
|
||||
"-filter_threads", "4",
|
||||
"-headers", douyin_headers,
|
||||
"-re",
|
||||
"-i", real_url,
|
||||
|
||||
# 视频处理 720p
|
||||
"-vf",
|
||||
"fps=25,scale=720:1280:force_original_aspect_ratio=decrease:flags=bicubic,"
|
||||
"pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast", # 降低CPU
|
||||
"-profile:v", "high",
|
||||
"-b:v", "2600k", "-maxrate", "3000k", "-bufsize", "5200k",
|
||||
"-vsync", "cfr",
|
||||
"-g", "50", "-keyint_min", "50", # 与fps=25匹配
|
||||
"-r", "25",
|
||||
"-bf", "0",
|
||||
"-sc_threshold", "0",
|
||||
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||
# bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x
|
||||
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=bilinear,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v,
|
||||
*video_args,
|
||||
*_video_mid,
|
||||
"-pix_fmt", "yuv420p",
|
||||
|
||||
# 音频处理(轻量 + 背景音乐弱化 + 防Content ID)
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
|
||||
"-af",
|
||||
"afftdn=nf=-28:tn=1," # 轻 FFT 降噪,背景弱化
|
||||
"highpass=f=120,lowpass=f=4800," # 切低频鼓 & 高频镲
|
||||
"equalizer=f=250:width_type=o:width=2:g=-8," # 衰减低中频
|
||||
"equalizer=f=800:width_type=o:width=1.5:g=-5," # 衰减中频
|
||||
"acompressor=threshold=-30dB:ratio=4:attack=10:release=100:makeup=4," # 压动态
|
||||
"asetrate=48000*1.02,aresample=48000," # 音高微升
|
||||
"afreqshift=shift=15," # 小幅频率偏移
|
||||
"volume=1.1," # 音量补偿
|
||||
"aresample=async=1:first_pts=0", # 音视频同步
|
||||
|
||||
# 推流参数
|
||||
"-af", _af_chain,
|
||||
"-flags", "+global_header",
|
||||
"-flvflags", "no_duration_filesize",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 1080p
|
||||
# ffmpeg_command = [
|
||||
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
# "-stats", "-stats_period", "1",
|
||||
# "-rw_timeout", "30000000",
|
||||
# "-reconnect", "1", "-reconnect_at_eof", "1",
|
||||
# "-reconnect_streamed", "1",
|
||||
# "-reconnect_delay_max", "5",
|
||||
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||
# "-flags", "low_delay",
|
||||
# "-err_detect", "ignore_err",
|
||||
# "-max_delay", "80000",
|
||||
# "-thread_queue_size", "2048",
|
||||
|
||||
# "-headers", douyin_headers,
|
||||
# "-i", real_url,
|
||||
|
||||
# # 视频:不动你原策略,只给编码喘口气
|
||||
# "-vf", "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease:flags=bicubic,"
|
||||
# "pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
# "-c:v", "libx264",
|
||||
# "-preset", "superfast",
|
||||
# "-tune", "zerolatency",
|
||||
# "-profile:v", "high",
|
||||
# "-b:v", "5200k", "-maxrate", "5800k", "-bufsize", "10400k",
|
||||
# "-vsync", "cfr",
|
||||
# "-g", "50", "-keyint_min", "50",
|
||||
# "-r", "25",
|
||||
# "-bf", "0",
|
||||
# "-sc_threshold", "0",
|
||||
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||
# "-pix_fmt", "yuv420p",
|
||||
|
||||
# # 音频
|
||||
# "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
|
||||
# "-af",
|
||||
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.80,"
|
||||
# "afftdn=nf=-18:tn=1,"
|
||||
# "highpass=f=120,lowpass=f=4600,"
|
||||
# "equalizer=f=250:width_type=o:width=2:g=-6,"
|
||||
# "equalizer=f=900:width_type=o:width=1.4:g=-4,"
|
||||
# "asetrate=48000*1.012,aresample=48000,"
|
||||
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=120:makeup=4,"
|
||||
# "volume=1.10,"
|
||||
# "aresample=async=1:first_pts=0",
|
||||
|
||||
# "-flvflags", "no_duration_filesize",
|
||||
# "-f", "flv", youtube_rtmp
|
||||
# ]
|
||||
|
||||
|
||||
|
||||
# ====================== 主重试循环(原样保留) ======================
|
||||
proc = None
|
||||
stderr_q = None
|
||||
reader_t = None
|
||||
@@ -324,15 +269,17 @@ def start_douyin_youtube_ffplay(
|
||||
stderr_q = queue.Queue()
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
_popen_kw = dict(
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
universal_newlines=True,
|
||||
)
|
||||
if _ffmpeg_cwd:
|
||||
_popen_kw["cwd"] = _ffmpeg_cwd
|
||||
proc = subprocess.Popen(ffmpeg_command, **_popen_kw)
|
||||
proc.last_out_ts = time.time()
|
||||
|
||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
||||
|
||||
14
index.html.1
Normal file
14
index.html.1
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="en"><head><meta content="Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for." name="description"><meta content="noodp, " name="robots"><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"><meta content="/images/branding/googleg/1x/googleg_standard_color_128dp.png" itemprop="image"><title>Google</title><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var _g={kEI:'gbObaeXAG-Gy5NoPmeGiyAg',kEXPI:'0,1304203,2935842,14111,64702,6398,354503,5532962,16,36811390,25228681,123988,28395,11943,53220,39775,61250,14067,23259,37800,28333,79315,7707,7,33385,4719,24374,1,2884,3086,16757,46340,9920,10754,11886,6436,4952,4791,2646,6292,12404,5550,14505,1383,1,2,1515,3355,196,5814,2,4205,1,16100,2,873,2,22,2208,5108,3008,24,771,4074,4,5393,18686,8466,5337,310,535,845,1201,2,11,2,1560,3,5189,1,13,738,1009,2,1746,4,4878,5773,4542,4,2243,240,297,1,1117,389,4,2647,5,2698,2870,298,9253,1275,7,1784,1050,2,1914,5,10456,2,5724,9,602,59,36,1554,4,2570,3341,932,3066,1,2,1402,3,1117,242,2,2356,2576,49,1,8,216,441,158,2,1645,191,4,1928,4,1634,544,4,1568,1032,1,1399,187,259,1846,5,259,5,197,1921,445,19,1421,119,4,2364,2363,5,39,633,1937,4,57,4,3784,4,76,1970,4,265,106,5,3,971,4,966,5,1125,1,2,2,815,3,2932,4,32,4,130,717,1856,4,40,4,2110,4,134,304,323,1,576,1776,1864,42,5,2,479,704,4,14,3,1,449,4,730,5,75,4,1918,1,21,4,465,429,537,1563,1,1090,2,265,702,4905,1788,14,507,823,747,4,331,4,959,452,57,258,615,80,7,2245,4,281,264,341,221,1,2976,508,339,3430,503,3,1412,1354,4,12,33,2093,4,945,16,4,612,1,20998529,1717,7635,5,2992,4,2960,3,8491,2,1562,3,2687,3,9181,536,2,2461,1194,3,746,6,918,231,3,1369,1494,2715,1,6494629,4214,2,3639,565,745,38,517,541,84,108428,25,36,10,3516226,209655,562,143,12493584,256605,48968,4054456,476,441,42,5,116,590,5,1023,1671,844,1,148,3,6,163,101,343,4,65,388,2,707,260,44,4,152,4,488,62,219,3,2,2,2,550,4,885,1050,2,50,641,5,429,6,2800,5,138,1,863,130,45,2,11,2,16,361,218,1148,1,2,642,214,1485,417,712,2695,1043,281,5,330,1255,292,114,289,54,161,5,142,4,587,412,322,525,1,361,503,213,249,99,472,165,7,51,90,174,436,472,3,289,379,458,583,331,601,587,556,100,949,821,118,4,177,37,7,12,3,6,25,1006,3,2,2,2,67,134,727,4,283,321,70,212,125,4,4,3,2,1,2,3,2,2,1,2,2,2,3,1,3,2,1,3,2,1,3,1,2,3,1,2,2,2,3,2,1,3,1,2,3,2,2,2,2,1,3,1,3,2,1,2,10,2,2,3,2,2,10,1,2,3,1,2,10,2,3,2,1,3,9,2,3,1,3,2,1,2,2,2,3,1,2,3,2,2,1,3,1,3,2,1,2,2,2,2,2,3,2,2,1,2,3,2,1,2,2,2,3,1,3,1,3,1,2,2,10,2,3,1,3,1,11,2,2,1,2,2,10,2,3,2,2,1,183,94,60,120,149,39,317,3,2,2,2,744,296,177,5,22,414,377,24,216,1579,8,293,133,136,134,83,118,2,3,2,2,2,956,1,2,785,3,2,2,2,348,295,4,106,229,180,5,62,2,878,573,260,185,936,640,1027,638,713,94,18,3,2,2,2,4,308,593,3,2,2,2,720,519,6,1,4357,427,3,1,370,435,364,71,61,442,901,4',kBL:'eaQ3',kOPI:89978449};(function(){var a;((a=window.google)==null?0:a.stvsc)?google.kEI=_g.kEI:window.google=_g;}).call(this);})();(function(){google.sn='webhp';google.kHL='en';google.rdn=true;})();(function(){
|
||||
var g=this||self;function k(){return window.google&&window.google.kOPI||null};var l,m=[];function n(a){for(var b;a&&(!a.getAttribute||!(b=a.getAttribute("eid")));)a=a.parentNode;return b||l}function p(a){for(var b=null;a&&(!a.getAttribute||!(b=a.getAttribute("leid")));)a=a.parentNode;return b}function q(a){/^http:/i.test(a)&&window.location.protocol==="https:"&&(google.ml&&google.ml(Error("a"),!1,{src:a,glmm:1}),a="");return a}
|
||||
function r(a,b,d,c,h){var e="";b.search("&ei=")===-1&&(e="&ei="+n(c),b.search("&lei=")===-1&&(c=p(c))&&(e+="&lei="+c));var f=b.search("&cshid=")===-1&&a!=="slh";c="&zx="+Date.now().toString();g._cshid&&f&&(c+="&cshid="+g._cshid);(d=d())&&(c+="&opi="+d);return"/"+(h||"gen_204")+"?atyp=i&ct="+String(a)+"&cad="+(b+e+c)};l=google.kEI;google.getEI=n;google.getLEI=p;google.ml=function(){return null};google.log=function(a,b,d,c,h,e){e=e===void 0?k:e;d||(d=r(a,b,e,c,h));if(d=q(d)){a=new Image;var f=m.length;m[f]=a;a.onerror=a.onload=a.onabort=function(){delete m[f]};a.src=d}};google.logUrl=function(a,b){b=b===void 0?k:b;return r("",a,b)};}).call(this);(function(){google.y={};google.sy={};function e(a,b,d){if(a)var c=a.id;else{do c=Math.random();while(d[c])}d[c]=[a,b]}var f;(f=google).x||(f.x=function(a,b){e(a,b,google.y)});var g;(g=google).sx||(g.sx=function(a,b){e(a,b,google.sy)});google.bx=!1;var h;(h=google).lx||(h.lx=function(){});var k=[],l;(l=google).fce||(l.fce=function(a,b,d,c){k.push([a,b,d,c])});google.qce=k;google.adl=[];}).call(this);google.f={};(function(){
|
||||
document.documentElement.addEventListener("submit",function(b){var a;if(a=b.target){var c=a.getAttribute("data-submitfalse");a=c==="1"||c==="q"&&!a.elements.q.value?!0:!1}else a=!1;a&&(b.preventDefault(),b.stopPropagation())},!0);document.documentElement.addEventListener("click",function(b){var a;a:{for(a=b.target;a&&a!==document.documentElement;a=a.parentElement)if(a.tagName==="A"){a=a.getAttribute("data-nohref")==="1";break a}a=!1}a&&b.preventDefault()},!0);}).call(this);</script><style>#gbar,#guser{font-size:13px;padding-top:1px !important;}#gbar{height:22px}#guser{padding-bottom:7px !important;text-align:right}.gbh,.gbd{border-top:1px solid #c9d7f1;font-size:1px}.gbh{height:0;position:absolute;top:24px;width:100%}@media all{.gb1{height:22px;margin-right:.5em;vertical-align:top}#gbar{float:left}}a.gb1,a.gb4{text-decoration:underline !important}a.gb1,a.gb4{color:#00c !important}.gbi .gb4{color:#dd8e27 !important}.gbf .gb4{color:#900 !important}
|
||||
</style><style>body,td,a,p,.h{font-family:sans-serif}body{margin:0;overflow-y:scroll}#gog{padding:3px 8px 0}td{line-height:.8em}.gac_m td{line-height:17px}form{margin-bottom:20px}.h{color:#1967d2}em{font-weight:bold;font-style:normal}.lst{height:25px;width:496px}.gsfi,.lst{font:18px sans-serif}.gsfs{font:17px sans-serif}.ds{display:inline-box;display:inline-block;margin:3px 0 4px;margin-left:4px}input{font-family:inherit}body{background:#fff;color:#1f1f1f}a{color:#681da8;text-decoration:none}a:hover,a:active{text-decoration:underline}.fl a{color:#1967d2}a:visited{color:#681da8}.sblc{padding-top:5px}.sblc a{display:block;margin:2px 0;margin-left:13px;font-size:11px}.lsbb{background:#ecedee;border:solid 1px;border-color:#d2d2d2 #70757a #70757a #d2d2d2;height:30px}.lsbb{display:block}#WqQANb a{display:inline-block;margin:0 12px}.lsb{background:url(/images/nav_logo229.png) 0 -261px repeat-x;color:#1f1f1f;border:none;cursor:pointer;height:30px;margin:0;outline:0;font:15px sans-serif;vertical-align:top}.lsb:active{background:#dadce0}.lst:focus{outline:none}</style><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.erd={jsr:1,bv:2383,de:true,dpf:'UAON6G49novXd1LJI--iGd6VfzOOuK54njDRAqwNAgA'};
|
||||
var g=this||self;var k,l=(k=g.mei)!=null?k:1,m,p=(m=g.diel)!=null?m:0,q,r=(q=g.sdo)!=null?q:!0,t=0,u,w=google.erd,x=w.jsr;google.ml=function(a,b,d,n,e){e=e===void 0?2:e;b&&(u=a&&a.message);d===void 0&&(d={});d.cad="ple_"+google.ple+".aple_"+google.aple;if(google.dl)return google.dl(a,e,d,!0),null;b=d;if(x<0){window.console&&console.error(a,b);if(x===-2)throw a;b=!1}else b=!a||!a.message||a.message==="Error loading script"||t>=l&&!n?!1:!0;if(!b)return null;t++;d=d||{};b=encodeURIComponent;var c="/gen_204?atyp=i&ei="+b(google.kEI);google.kEXPI&&(c+="&jexpid="+b(google.kEXPI));c+="&srcpg="+b(google.sn)+"&jsr="+b(w.jsr)+
|
||||
"&bver="+b(w.bv);w.dpf&&(c+="&dpf="+b(w.dpf));var f=a.lineNumber;f!==void 0&&(c+="&line="+f);var h=a.fileName;h&&(h.indexOf("-extension:/")>0&&(e=3),c+="&script="+b(h),f&&h===window.location.href&&(f=document.documentElement.outerHTML.split("\n")[f],c+="&cad="+b(f?f.substring(0,300):"No script found.")));google.ple&&google.ple===1&&(e=2);c+="&jsel="+e;for(var v in d)c+="&",c+=b(v),c+="=",c+=b(d[v]);c=c+"&emsg="+b(a.name+": "+a.message);c=c+"&jsst="+b(a.stack||"N/A");c.length>=12288&&(c=c.substring(0,12288));a=c;n||google.log(0,"",a);return a};window.onerror=function(a,b,d,n,e){u!==a&&(a=e instanceof Error?e:Error(a),d===void 0||"lineNumber"in a||(a.lineNumber=d),b===void 0||"fileName"in a||(a.fileName=b),google.ml(a,!1,void 0,!1,a.name==="SyntaxError"||a.message.substring(0,11)==="SyntaxError"||a.message.indexOf("Script error")!==-1?3:p));u=null;r&&t>=l&&(window.onerror=null)};})();</script></head><body bgcolor="#fff"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var src='/images/nav_logo229.png';var iesg=false;document.body.onload = function(){window.n && window.n();if (document.images){new Image().src=src;}
|
||||
if (!iesg){document.f&&document.f.q.focus();document.gbqf&&document.gbqf.q.focus();}
|
||||
}
|
||||
})();</script><div id="mngb"><div id=gbar><nobr><b class=gb1>Search</b> <a class=gb1 href="https://www.google.com/imghp?hl=en&tab=wi">Images</a> <a class=gb1 href="http://maps.google.com/maps?hl=en&tab=wl">Maps</a> <a class=gb1 href="https://play.google.com/?hl=en&tab=w8">Play</a> <a class=gb1 href="https://www.youtube.com/?tab=w1">YouTube</a> <a class=gb1 href="https://news.google.com/?tab=wn">News</a> <a class=gb1 href="https://mail.google.com/mail/?tab=wm">Gmail</a> <a class=gb1 href="https://drive.google.com/?tab=wo">Drive</a> <a class=gb1 style="text-decoration:none" href="https://www.google.com/intl/en/about/products?tab=wh"><u>More</u> »</a></nobr></div><div id=guser width=100%><nobr><span id=gbn class=gbi></span><span id=gbf class=gbf></span><span id=gbe></span><a href="http://www.google.com/history/optout?hl=en" class=gb4>Web History</a> | <a href="/preferences?hl=en" class=gb4>Settings</a> | <a target=_top id=gb_70 href="https://accounts.google.com/ServiceLogin?hl=en&passive=true&continue=http://www.google.com/&ec=GAZAAQ" class=gb4>Sign in</a></nobr></div><div class=gbh style=left:0></div><div class=gbh style=right:0></div></div><center><br clear="all" id="lgpd"><div><img alt="Google" height="92" src="/images/branding/googlelogo/1x/googlelogo_white_background_color_272x92dp.png" style="padding:28px 0 14px" width="272" id="hplogo"><br><br></div><form action="/search" name="f"><table cellpadding="0" cellspacing="0"><tr valign="top"><td width="25%"> </td><td align="center" nowrap=""><input name="ie" value="ISO-8859-1" type="hidden"><input value="en" name="hl" type="hidden"><input name="source" type="hidden" value="hp"><input name="biw" type="hidden"><input name="bih" type="hidden"><div class="ds" style="height:32px;margin:4px 0"><input class="lst" style="margin:0;padding:5px 8px 0 6px;vertical-align:top;color:#1f1f1f" autocomplete="off" value="" title="Google Search" maxlength="2048" name="q" size="57"></div><br style="line-height:0"><span class="ds"><span class="lsbb"><input class="lsb" value="Google Search" name="btnG" type="submit"></span></span><span class="ds"><span class="lsbb"><input class="lsb" id="tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1" value="I'm Feeling Lucky" name="btnI" type="submit"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var id='tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1';document.getElementById(id).onclick = function(){if (this.form.q.value){this.checked = 1;if (this.form.iflsig)this.form.iflsig.disabled = false;}
|
||||
else top.location='/doodles/';};})();</script><input value="AFdpzrgAAAAAaZvBkZ6x6tP9yqY4FfFqM-NYRs9cR3zt" name="iflsig" type="hidden"></span></span></td><td class="fl sblc" align="left" nowrap="" width="25%"><a href="/advanced_search?hl=en&authuser=0">Advanced search</a></td></tr></table><input id="gbv" name="gbv" type="hidden" value="1"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var a,b="1";if(document&&document.getElementById)if(typeof XMLHttpRequest!="undefined")b="2";else if(typeof ActiveXObject!="undefined"){var c,d,e=["MSXML2.XMLHTTP.6.0","MSXML2.XMLHTTP.3.0","MSXML2.XMLHTTP","Microsoft.XMLHTTP"];for(c=0;d=e[c++];)try{new ActiveXObject(d),b="2"}catch(h){}}a=b;if(a=="2"&&location.search.indexOf("&gbv=2")==-1){var f=google.gbvu,g=document.getElementById("gbv");g&&(g.value=a);f&&window.setTimeout(function(){location.href=f},0)};}).call(this);</script></form><div style="font-size:83%;min-height:3.5em"><br></div><span id="footer"><div style="font-size:10pt"><div style="margin:19px auto;text-align:center" id="WqQANb"><a href="/intl/en/ads/">Advertising</a><a href="/services/">Business Solutions</a><a href="/intl/en/about.html">About Google</a></div></div><p style="font-size:8pt;color:#636363">© 2026 - <a href="/intl/en/policies/privacy/">Privacy</a> - <a href="/intl/en/policies/terms/">Terms</a></p></span></center><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.cdo={height:757,width:1440};(function(){var a=window.innerWidth,b=window.innerHeight;if(!a||!b){var c=window.document,d=c.compatMode=="CSS1Compat"?c.documentElement:c.body;a=d.clientWidth;b=d.clientHeight}if(a&&b&&(a!=google.cdo.width||b!=google.cdo.height)){var e=google,f=e.log,g="/client_204?&atyp=i&biw="+a+"&bih="+b+"&ei="+google.kEI,h="",k=window.google&&window.google.kOPI||null;k&&(h+="&opi="+k);f.call(e,"","",g+h)};}).call(this);})();(function(){google.xjs={basecomb:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/ck\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAICAQAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAIAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d0/ujg\x3d1/rs\x3dACT90oHYvixg5Dv0yQj-SB1JU6LIcj3yYQ',basecss:'/xjs/_/ss/k\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAIAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAI/rs\x3dACT90oFMtqhjIKGT4rLyv9oL3Z6ncHStwQ',basejs:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/dg\x3d0/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA',excm:[]};})();(function(){var u='/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d4/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA/m\x3dsb_he,d';var st=1;var amd=1000;var mmd=0;var pop=true;var povp=false;var raf=false;var lfpp=false;var ecb=false;var cst=false;var pxp=false;
|
||||
var f=this||self;function g(){var a=google.ia&&google.ia.r.B2Jtyd;return a&&[1,5,6].indexOf(a.m)>=0&&a.cbfd&&a.cbvi?a:void 0};function h(a){var b=document.createElement("link");b.as="script";b.href=a;b.rel="preload";document.body.appendChild(b)}function k(){var a=[l];google.dp||(a.forEach(h),google.dp=!0)};google.ps=google.ps||[];function m(a){var b=l,c=function(){};google.lx=google.stvsc?c:function(){n(b,a&&raf,a&&lfpp);google.lx=c};google.bx||google.lx()}function p(a,b){b&&(a.src=b);var c=a.onload;a.onload=function(d){c&&c(d);google.ps=google.ps.filter(function(e){return a.src!==e})};google.ps.push(a.src);document.body.appendChild(a)}function q(a,b,c,d){c&&(a.fetchPriority="low");b?requestAnimationFrame(function(){p(a,d)}):p(a,d)}google.as=p;function n(a,b,c){google.tick&&google.tick("load","xjsls");var d=document.createElement("script");d.onerror=function(){google.ple=1};d.onload=function(){google.ple=0};google.xjsus=void 0;q(d,b,c,a);google.aple=-1;google.dp=!0};function r(){for(var a=document.getElementsByTagName("img"),b=0,c=a.length;b<c;b++){var d=a[b],e;if(e=d.hasAttribute("data-lzy_")&&Number(d.getAttribute("data-atf"))&1)e=d.getAttribute("jscontroller"),e=!((e==="UBXHI"||e==="R3fhkb"||e==="TSZEqd")&&d.hasAttribute("data-src"));if(e)return!0}return!1};var l,t,w,x,y,z,A,B,C,D,E="";function F(){l=pxp&&google.xjsup||u;var a=l.match("/cb=(loaded_h_\\d+)");a&&a[1]&&(E=a[1]);google.xjsu=l;f._F_jsUrl=l;z=function(c){m(c)};t=!1;w=(st===1||st===3)&&!!google.caft&&!r();x=g();y=!E&&(st===2||st===3)&&!!x&&!r();if(E){var b=(st===2||st===3)&&!!x;f[E]=function(c){var d=!1,e=function(){d||(d=!0,cst?setTimeout(function(){return void c.call(window,window._)},0):c.call(window,window._))};b&&google.ia.adls?(x.cbvi.push(function(){delete google.ia.adls}),x.cbvi.push(e),setTimeout(e,mmd)):e()}}A=
|
||||
pop;B=povp;C=A&&document.prerendering||B&&document.hidden;D=B?"visibilitychange":"prerenderingchange"}function G(a){t||w||y||C||(z(a),t=!0)}
|
||||
setTimeout(function(){google.tick&&google.tick("load","xjspls");F();if(w||y||C){if(w){var a=function(){w=!1;G()};google.caft(a);setTimeout(a,amd)}y&&(a=function(){y=!1;G()},x.cbvi.push(a),setTimeout(a,mmd));if(C){var b=function(){(B?document.hidden:document.prerendering)||(C=!1,G(!B),document.removeEventListener(D,b))};document.addEventListener(D,b,{passive:!0})}t||k()}else z()},0);})();window._ = window._ || {};window._DumpException = _._DumpException = function(e){throw e;};window._s = window._s || {};_s._DumpException = _._DumpException;window._qs = window._qs || {};_qs._DumpException = _._DumpException;window.loaded_h_0 = function(cb){cb.call(window,window._);};(function(){var t=[512,0,2048,0,0,0,4194304,33554432,268437004,0,8192,0,4096,4259020,137036800,134234113,548405252,4196417,8192,134217728,1048576,8,1170496,0,72908928,0,568328192];window._F_toggles = window._xjs_toggles = t;})();window._F_installCss = window._F_installCss || function(css){};(function(){var pmc='{\x22d\x22:{},\x22sb_he\x22:{\x22client\x22:\x22heirloom-hp\x22,\x22dh\x22:true,\x22ds\x22:\x22\x22,\x22host\x22:\x22google.com\x22,\x22jsonp\x22:true,\x22msgs\x22:{\x22cibl\x22:\x22Clear Search\x22,\x22dym\x22:\x22Did you mean:\x22,\x22lcky\x22:\x22I\\u0026#39;m Feeling Lucky\x22,\x22lml\x22:\x22Learn more\x22,\x22psrc\x22:\x22This search was removed from your \\u003Ca href\x3d\\\x22/history\\\x22\\u003EWeb History\\u003C/a\\u003E\x22,\x22psrl\x22:\x22Remove\x22,\x22sbit\x22:\x22Search by image\x22,\x22srch\x22:\x22Google Search\x22},\x22ovr\x22:{},\x22pq\x22:\x22\x22,\x22rfs\x22:[],\x22stok\x22:\x22VgWHNe9WWnZFTDWuPRUlX6IPsTw\x22}}';google.pmc=JSON.parse(pmc);})();</script></body></html>
|
||||
86
web.py
86
web.py
@@ -9,15 +9,15 @@ app = FastAPI(title="多进程录制控制台")
|
||||
|
||||
# ---------------- 配置(根目录结构) ----------------
|
||||
BASE_DIR = Path(__file__).parent
|
||||
CONFIG_DIR = BASE_DIR / "config" # config 文件夹在项目根目录
|
||||
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" # .pm2/logs 在项目根目录
|
||||
CONFIG_DIR = BASE_DIR / "config"
|
||||
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
|
||||
|
||||
MAX_LOG_LINES = 300
|
||||
|
||||
# 支持的进程列表
|
||||
VALID_PROCESSES = {"tiktok", "youtube", "obs"}
|
||||
|
||||
# CORS(支持 GET 和 POST)
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -25,22 +25,45 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---------------- 工具 ----------------
|
||||
# ---------------- 工具函数 ----------------
|
||||
def strip_ansi_codes(text: str) -> str:
|
||||
return re.sub(r'\x1b\[[0-9;]*m', '', text)
|
||||
|
||||
async def run_pm2(cmd: str) -> str:
|
||||
|
||||
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"pm2 {cmd}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
|
||||
return (stdout or stderr).decode(errors="ignore").strip()
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
result = (stdout or stderr).decode(errors="ignore").strip()
|
||||
return result if result else "命令执行完成(无输出)"
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return "ERROR: PM2 命令超时"
|
||||
await process.wait()
|
||||
return "ERROR: PM2 命令超时,已强制终止"
|
||||
|
||||
|
||||
async def force_kill_ffmpeg(process_name: str):
|
||||
"""尝试强杀与该进程相关的 ffmpeg"""
|
||||
cmds = [
|
||||
# 优先杀带有进程名的 ffmpeg(最精准)
|
||||
f"pkill -f 'ffmpeg.*{process_name}' || true",
|
||||
# 再杀所有 ffmpeg(兜底,比较暴力)
|
||||
"killall -9 ffmpeg 2>/dev/null || true",
|
||||
]
|
||||
|
||||
for cmd in cmds:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
await asyncio.sleep(0.4) # 稍微间隔,避免竞争
|
||||
|
||||
|
||||
async def read_log_path(path: str, lines: int) -> str:
|
||||
if not path:
|
||||
@@ -57,21 +80,22 @@ async def read_log_path(path: str, lines: int) -> str:
|
||||
except Exception as e:
|
||||
return f"读取日志失败 ({path}): {str(e)}\n"
|
||||
|
||||
|
||||
async def get_process_info(process: str) -> dict:
|
||||
describe_raw = await run_pm2(f"describe {process}")
|
||||
describe_raw = await run_pm2(f"describe {process}", timeout=10.0)
|
||||
describe_clean = strip_ansi_codes(describe_raw)
|
||||
if ("No such process" in describe_clean or
|
||||
"Unknown process" in describe_clean or
|
||||
"not found" in describe_clean.lower() or
|
||||
not describe_clean.strip()):
|
||||
|
||||
if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip():
|
||||
return {
|
||||
"process_status": "not_found",
|
||||
"out_path": None,
|
||||
"err_path": None,
|
||||
}
|
||||
|
||||
status = "unknown"
|
||||
out_path = None
|
||||
err_path = None
|
||||
|
||||
for line in describe_raw.splitlines():
|
||||
line_clean = strip_ansi_codes(line)
|
||||
if line_clean.count('│') < 2:
|
||||
@@ -86,11 +110,12 @@ async def get_process_info(process: str) -> dict:
|
||||
out_path = value
|
||||
elif key == "err_log_path":
|
||||
err_path = value
|
||||
# 备用默认路径
|
||||
|
||||
if not out_path:
|
||||
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
|
||||
if not err_path:
|
||||
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
|
||||
|
||||
return {
|
||||
"process_status": status,
|
||||
"out_path": out_path,
|
||||
@@ -173,20 +198,41 @@ async def start(process: str = Query(..., description="进程名")):
|
||||
output = await run_pm2(f"start {process}")
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
|
||||
@app.get("/stop")
|
||||
async def stop(process: str = Query(..., description="进程名")):
|
||||
if process not in VALID_PROCESSES:
|
||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||
output = await run_pm2(f"stop {process}")
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
# 第一步:尝试优雅停止
|
||||
pm2_output = await run_pm2(f"stop {process}")
|
||||
|
||||
# 第二步:等待 PM2 信号传播(通常 1~3 秒)
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
# 第三步:主动查找并杀死 ffmpeg
|
||||
await force_kill_ffmpeg(process)
|
||||
|
||||
# 第四步:再等一小会儿,确认
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
return JSONResponse({
|
||||
"message": "已执行 stop + 强杀 ffmpeg",
|
||||
"pm2_output": pm2_output,
|
||||
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
|
||||
})
|
||||
|
||||
|
||||
@app.get("/restart")
|
||||
async def restart(process: str = Query(..., description="进程名")):
|
||||
if process not in VALID_PROCESSES:
|
||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||
output = await run_pm2(f"restart {process}")
|
||||
# 重启后也建议清理一次残留(视情况可选)
|
||||
await force_kill_ffmpeg(process)
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
async def status(process: str = Query(..., description="进程名")):
|
||||
if process not in VALID_PROCESSES:
|
||||
@@ -196,14 +242,17 @@ async def status(process: str = Query(..., description="进程名")):
|
||||
"recent_log": f"无效进程名: {process}",
|
||||
"recent_error": ""
|
||||
})
|
||||
full_status_raw = await run_pm2("status")
|
||||
|
||||
full_status_raw = await run_pm2("status", timeout=8.0)
|
||||
full_status = strip_ansi_codes(full_status_raw)
|
||||
info = await get_process_info(process)
|
||||
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
|
||||
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
|
||||
|
||||
if info["process_status"] == "not_found":
|
||||
recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n"
|
||||
recent_error = ""
|
||||
|
||||
return JSONResponse({
|
||||
"raw_status": full_status,
|
||||
"process_status": info["process_status"],
|
||||
@@ -211,7 +260,8 @@ async def status(process: str = Query(..., description="进程名")):
|
||||
"recent_error": recent_error,
|
||||
})
|
||||
|
||||
|
||||
# ---------------- 首页 ----------------
|
||||
@app.get("/")
|
||||
async def index():
|
||||
return FileResponse(BASE_DIR / "index.html")
|
||||
return FileResponse(BASE_DIR / "index.html")
|
||||
Reference in New Issue
Block a user