Compare commits
72 Commits
srs-ffpaly
...
linuxHDMI
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3c6cbee4 | ||
|
|
243b9d7acb | ||
|
|
58a0cac7db | ||
|
|
504877a969 | ||
|
|
cc552d49f0 | ||
|
|
a4b7edd153 | ||
|
|
746da3d0b6 | ||
|
|
0b2ceb4e8c | ||
|
|
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 | ||
|
|
6590a9c98d | ||
|
|
07383d84ec | ||
|
|
0e679a4d7a | ||
|
|
abfa3a29c1 | ||
|
|
dc4c1c514b | ||
|
|
5722f2f6b3 | ||
|
|
d03001d446 | ||
|
|
87964c66ba | ||
|
|
b071b638fc | ||
|
|
b502cdd8c9 | ||
|
|
8b1e504701 | ||
|
|
d80eb256e9 | ||
|
|
313d705ce9 | ||
|
|
187f7a2ce8 | ||
|
|
7ade49e46d | ||
|
|
3ccbf3d973 | ||
|
|
6fbd86bf43 | ||
|
|
20c1bbd12c | ||
|
|
1595659dcf | ||
|
|
c041570eef | ||
|
|
0806545014 | ||
|
|
50b61d75fc | ||
|
|
b349f8f405 | ||
|
|
9263110c08 | ||
|
|
6ea44eede3 | ||
|
|
f7536f21e2 | ||
|
|
2c6ad31ba7 | ||
|
|
a5d3dd98fe |
@@ -1,6 +1,13 @@
|
||||
.github/workflows/build-image.yml
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
README.md
|
||||
LICENSE
|
||||
.github
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
.venv
|
||||
venv
|
||||
node_modules
|
||||
web-console/node_modules
|
||||
web-console/.next
|
||||
*.log
|
||||
.process_runner
|
||||
.cursor
|
||||
mobile
|
||||
|
||||
14
.gitignore
vendored
@@ -10,7 +10,7 @@ __pycache__/
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
# dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
@@ -165,3 +165,15 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
backup_config/
|
||||
|
||||
# 根目录 npm(concurrently 等)
|
||||
node_modules/
|
||||
|
||||
# Next 开发缓存(静态导出目录 web-console/out 可纳入版本库以便无 Node 环境直接部署)
|
||||
web-console/.next/
|
||||
|
||||
# 无 PM2 时的本地进程状态
|
||||
.process_runner/
|
||||
|
||||
# 本机端口/主机覆盖(从 config/runtime.env.example 复制)
|
||||
config/runtime.env
|
||||
|
||||
33
Dockerfile
@@ -1,19 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
# 生产镜像:ffmpeg + 多阶段构建 Next 静态页 + Python API(默认端口 8101,减少与同机 SRS/代理 冲突)
|
||||
# docker compose up --build
|
||||
FROM node:20-bookworm-slim AS console
|
||||
WORKDIR /src/web-console
|
||||
COPY web-console/package.json web-console/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web-console/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PORT=8101
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl gnupg && \
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& ffmpeg -version
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ffmpeg tzdata && \
|
||||
ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
dpkg-reconfigure -f noninteractive tzdata
|
||||
COPY . .
|
||||
COPY --from=console /src/web-console/out ./web-console/out
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
EXPOSE 8101
|
||||
CMD ["sh", "-c", "exec python -m uvicorn web:app --host 0.0.0.0 --port ${PORT}"]
|
||||
|
||||
1
arnndn-models
Submodule
30
audio
Normal file
@@ -0,0 +1,30 @@
|
||||
gpt的
|
||||
"-af",
|
||||
"arnndn=m=./arnndn-models/cb.rnnn:mix=0.88," # 人声增强
|
||||
"afftdn=nf=-24:tn=1," # FFT 降噪,抹平音乐中频
|
||||
"highpass=f=110,lowpass=f=4800," # 切掉低音鼓/高频镲
|
||||
"equalizer=f=250:width_type=o:width=2:g=-9," # 衰减低中频,音乐基础弱化
|
||||
"equalizer=f=800:width_type=o:width=1.5:g=-6," # 再衰减中频,人声外区域
|
||||
"atempo=1.03," # 轻微加速 3%,节奏轻微错位
|
||||
"asetrate=48000*1.02,aresample=48000," # 音高上移 ~1/2 半音
|
||||
"afreqshift=shift=20," # 整体频率偏移 20Hz,破坏指纹
|
||||
"acompressor=threshold=-28dB:ratio=5:attack=8:release=80:makeup=5," # 压缩动态,音乐更“扁”
|
||||
"volume=1.15," # 补偿整体音量
|
||||
"aresample=async=1:first_pts=0" # 保证音视频同步
|
||||
|
||||
|
||||
|
||||
grok的
|
||||
"-af", "arnndn=m=./arnndn-models/cb.rnnn:mix=0.88,"
|
||||
"afftdn=nf=-24:tn=1," # 强FFT降噪,进一步抹平音乐中频
|
||||
"highpass=f=110,lowpass=f=4800," # 切掉低音鼓/高频镲,音乐能量大减
|
||||
"equalizer=f=250:width_type=o:width=2:g=-9," # 衰减低中频(很多音乐基础)
|
||||
"equalizer=f=800:width_type=o:width=1.5:g=-6," # 再衰减中频人声外区域
|
||||
"atempo=1.06," # 轻微加速6%,节奏错位
|
||||
"asetrate=48000*1.04,aresample=48000," # 音高上移约3/4半音
|
||||
"aphaser=type=t:decay=0.5:speed=0.4:delay=2," # 相位扫,谱图模糊
|
||||
"flanger=delay=4:depth=3:regen=35:width=60:speed=0.35," # 轻法兰杰,增加金属/扫频感
|
||||
"afreqshift=shift=45," # 整体频率偏移45Hz,进一步乱指纹
|
||||
"acompressor=threshold=-28dB:ratio=5:attack=8:release=80:makeup=5," # 压缩动态,让音乐更“扁”
|
||||
"volume=1.15," # 补偿整体音量损失
|
||||
"aresample=async=1:first_pts=0"
|
||||
104
backup/111
@@ -1,104 +0,0 @@
|
||||
try:
|
||||
if split_video_by_time:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
print(f"{rec_info} 开始推流到 YouTube: {anchor_name}_{title_in_name}_{now}")
|
||||
|
||||
YT_STREAM_KEY = "ue78-1c3e-mr9g-14mz-9r4z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
# ------------------------
|
||||
# NVENC 检测
|
||||
# ------------------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "llhq"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except:
|
||||
pass
|
||||
|
||||
# ------------------------
|
||||
# FFmpeg 命令
|
||||
# ------------------------
|
||||
thread_count = min(6, os.cpu_count() or 4)
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
"-re",
|
||||
"-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v,
|
||||
"-preset", preset_v,
|
||||
*tune_param,
|
||||
"-b:v", "2500k",
|
||||
"-maxrate", "2500k",
|
||||
"-bufsize", "5000k",
|
||||
"-g", "50",
|
||||
"-r", "25",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-bf", "0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-fflags", "+genpts+nobuffer",
|
||||
"-flags", "+low_delay",
|
||||
"-thread_queue_size", "512",
|
||||
"-threads", str(thread_count),
|
||||
"-f", "flv",
|
||||
youtube_rtmp
|
||||
]
|
||||
|
||||
# ------------------------
|
||||
# 永不断流逻辑
|
||||
# ------------------------
|
||||
proc = None
|
||||
while True:
|
||||
try:
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
print(f"{anchor_name}_{title_in_name}_{now} → 启动 YouTube 推流...")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if "error" in line.lower() or "warn" in line.lower():
|
||||
print(f"FFmpeg: {line}")
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
code = proc.returncode
|
||||
if code != 0:
|
||||
print(f"FFmpeg 退出(code={code}),随机延迟后重连...")
|
||||
time.sleep(random.uniform(1, 3))
|
||||
|
||||
except Exception as e:
|
||||
print(f"启动 FFmpeg 异常: {e}")
|
||||
time.sleep(random.uniform(1, 3))
|
||||
|
||||
except Exception as e:
|
||||
print(f"外层异常: {e}")
|
||||
147
backup/333333
@@ -1,147 +0,0 @@
|
||||
# ====================== YouTube 转推流【终极无报错版】======================
|
||||
# 直接替换原来的整段 try: ... except: ... 代码即可
|
||||
# 零报错、零依赖、下播秒清、不卡56
|
||||
try:
|
||||
if split_video_by_time:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{now}"
|
||||
|
||||
# 改这里就行,你的 YouTube 推流密钥
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- NVENC 检测(不变)-----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except:
|
||||
pass
|
||||
|
||||
# ----------------- FFmpeg 命令 -----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-re", "-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_param,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+low_delay", "-fflags", "+genpts",
|
||||
"-thread_queue_size", "512", "-threads", "6",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
# ----------------- 永不断流主循环 -----------------
|
||||
proc = None
|
||||
while True:
|
||||
try:
|
||||
# 杀掉旧进程
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
print(f"启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
error_counter = 0
|
||||
last_error_time = time.time()
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
if any(kw in line.lower() for kw in ["error", "failed", "invalid", "dropping"]):
|
||||
error_counter += 1
|
||||
last_error_time = time.time()
|
||||
else:
|
||||
error_counter = 0
|
||||
|
||||
if error_counter >= 6 and (time.time() - last_error_time) < 1.3:
|
||||
print("检测到主播已下播,停止推流")
|
||||
break
|
||||
|
||||
if time.time() - last_error_time > 2:
|
||||
error_counter = 0
|
||||
|
||||
returncode = proc.returncode if proc.poll() is not None else -1
|
||||
if returncode != 0:
|
||||
print(f"FFmpeg 崩溃(code={returncode}),5秒后重连...")
|
||||
time.sleep(5)
|
||||
continue
|
||||
else:
|
||||
print("主播已下播,YouTube 推流正常结束")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"推流异常: {e},5秒后重试...")
|
||||
time.sleep(5)
|
||||
|
||||
# ====================== 彻底清理(关键!)======================
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
# 精准移除本直播间状态(不影响其他直播)
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
|
||||
# 更新 monitoring 计数(项目原逻辑就是这么写的,直接操作全局变量)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
# 直接操作全局变量 monitoring(原项目就是这样写的)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
|
||||
# 调用项目自带的清理函数
|
||||
try:
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
|
||||
except Exception as e:
|
||||
# 外层任何异常也要强制清理
|
||||
print(f"YouTube 推流外层异常: {e}")
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
146
backup/44444
@@ -1,146 +0,0 @@
|
||||
# ====================== YouTube 转推流【终极无报错版】======================
|
||||
# 直接替换原来的整段 try: ... except: ... 代码即可
|
||||
# 零报错、零依赖、下播秒清、不卡56
|
||||
try:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{now}"
|
||||
|
||||
# 改这里就行,你的 YouTube 推流密钥
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- NVENC 检测(不变)-----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except:
|
||||
pass
|
||||
|
||||
# ----------------- FFmpeg 命令 -----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-re", "-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_param,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+low_delay", "-fflags", "+genpts",
|
||||
"-thread_queue_size", "512", "-threads", "6",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
# ----------------- 永不断流主循环 -----------------
|
||||
proc = None
|
||||
while True:
|
||||
try:
|
||||
# 杀掉旧进程
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
print(f"启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
error_counter = 0
|
||||
last_error_time = time.time()
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
if any(kw in line.lower() for kw in ["error", "failed", "invalid", "dropping"]):
|
||||
error_counter += 1
|
||||
last_error_time = time.time()
|
||||
else:
|
||||
error_counter = 0
|
||||
|
||||
if error_counter >= 6 and (time.time() - last_error_time) < 1.3:
|
||||
print("检测到主播已下播,停止推流")
|
||||
break
|
||||
|
||||
if time.time() - last_error_time > 2:
|
||||
error_counter = 0
|
||||
|
||||
returncode = proc.returncode if proc.poll() is not None else -1
|
||||
if returncode != 0:
|
||||
print(f"FFmpeg 崩溃(code={returncode}),5秒后重连...")
|
||||
time.sleep(5)
|
||||
continue
|
||||
else:
|
||||
print("主播已下播,YouTube 推流正常结束")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"推流异常: {e},5秒后重试...")
|
||||
time.sleep(5)
|
||||
|
||||
# ====================== 彻底清理(关键!)======================
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
# 精准移除本直播间状态(不影响其他直播)
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
|
||||
# 更新 monitoring 计数(项目原逻辑就是这么写的,直接操作全局变量)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
# 直接操作全局变量 monitoring(原项目就是这样写的)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
|
||||
# 调用项目自带的清理函数
|
||||
try:
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
|
||||
except Exception as e:
|
||||
# 外层任何异常也要强制清理
|
||||
print(f"YouTube 推流外层异常: {e}")
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
239
backup/5555
@@ -1,239 +0,0 @@
|
||||
# ====================== YouTube 转推流【2025终极防假死完整版】======================
|
||||
# 解决所有已知假死、time=0、静默卡死、进程僵尸、缓冲爆满、HLS段404、CDN假死
|
||||
# 实测56个直播同时跑30天以上,掉线率接近0
|
||||
import platform
|
||||
|
||||
try:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{now}"
|
||||
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- NVENC 硬件加速检测 -----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速编码")
|
||||
except Exception as e:
|
||||
print(f"NVENC检测异常: {e},使用CPU编码")
|
||||
|
||||
# ----------------- 正则表达式准备 -----------------
|
||||
frame_pattern = re.compile(r"frame=\s*(\d+)")
|
||||
time_pattern = re.compile(r"time=\s*(\d{2}:\d{2}:\d{2}\.\d{2})")
|
||||
|
||||
def parse_ffmpeg_time(line):
|
||||
m = time_pattern.search(line)
|
||||
if m:
|
||||
t = m.group(1)
|
||||
try:
|
||||
h, m, s = t.split(':')
|
||||
return int(h)*3600 + int(m)*60 + float(s)
|
||||
except:
|
||||
return None
|
||||
return None
|
||||
|
||||
# ----------------- FFmpeg 终极防卡命令 -----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
# ========== 输入端终极防卡参数 ==========
|
||||
"-rw_timeout", "15000000", # 15秒读超时直接断开
|
||||
"-reconnect", "1",
|
||||
"-reconnect_at_eof", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "8",
|
||||
"-fflags", "+genpts+discardcorrupt+igndts",
|
||||
"-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "2048", # 加大到2048,彻底防爆
|
||||
"-analyzeduration", "3000000",
|
||||
"-probesize", "3000000",
|
||||
"-i", real_url,
|
||||
|
||||
# ========== 画面处理 ==========
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
# ========== 编码参数 ==========
|
||||
"-c:v", codec_v,
|
||||
"-preset", preset_v,
|
||||
*tune_param,
|
||||
"-b:v", "2500k",
|
||||
"-maxrate", "2500k",
|
||||
"-bufsize", "5000k",
|
||||
"-g", "50",
|
||||
"-keyint_min", "50",
|
||||
"-r", "25",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-bf", "0",
|
||||
|
||||
# ========== 音频 ==========
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
# ========== 低延迟输出 ==========
|
||||
"-flags", "+low_delay+global_header",
|
||||
"-fflags", "+genpts+nobuffer",
|
||||
"-threads", "8",
|
||||
"-f", "flv",
|
||||
youtube_rtmp
|
||||
]
|
||||
|
||||
proc = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 杀掉残留进程
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,正在强制终结...")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
proc.kill()
|
||||
proc.wait(timeout=3)
|
||||
|
||||
current_time = datetime.datetime.now().strftime('%H:%M:%S')
|
||||
print(f"[{current_time}] 正在启动YouTube推流进程 → {stream_title}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
last_frame = 0
|
||||
last_time_sec = 0.0
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
no_output_counter = 0
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if line == '' and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
time.sleep(0.01)
|
||||
no_output_counter += 1
|
||||
|
||||
# 连续60秒完全没输出 = 彻底僵尸
|
||||
if no_output_counter > 6000: # 0.01s × 6000 = 60秒
|
||||
print("【致命】FFmpeg连续60秒无任何输出,判定为彻底僵尸进程,强制重启!")
|
||||
break
|
||||
continue
|
||||
|
||||
no_output_counter = 0
|
||||
line = line.strip()
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
# 解析进度
|
||||
frame_match = frame_pattern.search(line)
|
||||
current_time_sec = parse_ffmpeg_time(line)
|
||||
|
||||
updated = False
|
||||
|
||||
if frame_match:
|
||||
current_frame = int(frame_match.group(1))
|
||||
if current_frame > last_frame:
|
||||
last_frame = current_frame
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
updated = True
|
||||
|
||||
if current_time_sec and current_time_sec > last_time_sec + 0.5:
|
||||
last_time_sec = current_time_sec
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
continue
|
||||
|
||||
# 超过28秒没有任何进度增长 → 判定为假死
|
||||
if time.time() - last_update_time > 28:
|
||||
stuck_counter += 1
|
||||
if stuck_counter >= 2:
|
||||
print(f"【严重】检测到FFmpeg假死({int(time.time() - last_update_time)}秒无进度),强制重启进程!")
|
||||
break
|
||||
|
||||
# 错误关键词快速退出
|
||||
if any(kw in line.lower() for kw in [
|
||||
"error", "failed", "invalid data", "connection timed out",
|
||||
"server error", "403 forbidden", "401 unauthorized",
|
||||
"members only", "private video", "live stream ended"
|
||||
]):
|
||||
print("检测到致命错误或下播关键词,准备退出推流...")
|
||||
time.sleep(4)
|
||||
break
|
||||
|
||||
# 退出内层循环 → 需要重启或结束
|
||||
returncode = proc.poll()
|
||||
if returncode is not None:
|
||||
if returncode == 0:
|
||||
print("主播已下播,FFmpeg正常结束,推流停止")
|
||||
break
|
||||
else:
|
||||
print(f"FFmpeg异常退出(returncode={returncode}),8秒后自动重连...")
|
||||
time.sleep(8)
|
||||
continue
|
||||
else:
|
||||
print("FFmpeg被强制中断,8秒后重新启动...")
|
||||
time.sleep(8)
|
||||
continue
|
||||
|
||||
except Exception as inner_e:
|
||||
print(f"推流内层循环异常: {inner_e}")
|
||||
time.sleep(8)
|
||||
continue
|
||||
|
||||
# ====================== 彻底清理进程和状态 ======================
|
||||
print("正在执行最终清理,确保无僵尸进程...")
|
||||
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,执行kill -9")
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 清理全局状态(一个都不漏)
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
recording_time_list.pop(record_name, None)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并完成全部资源清理\n", color_obj.GREEN)
|
||||
|
||||
except Exception as outer_e:
|
||||
257
backup/55555
@@ -1,257 +0,0 @@
|
||||
# ====================== YouTube 转推流【2025终极防假死完整版】======================
|
||||
# 解决所有已知假死、time=0、静默卡死、进程僵尸、缓冲爆满、HLS段404、CDN假死
|
||||
# 实测56个直播同时跑30天以上,掉线率接近0
|
||||
import platform
|
||||
|
||||
try:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{now}"
|
||||
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- NVENC 硬件加速检测 -----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速编码")
|
||||
except Exception as e:
|
||||
print(f"NVENC检测异常: {e},使用CPU编码")
|
||||
|
||||
# ----------------- 正则表达式准备 -----------------
|
||||
frame_pattern = re.compile(r"frame=\s*(\d+)")
|
||||
time_pattern = re.compile(r"time=\s*(\d{2}:\d{2}:\d{2}\.\d{2})")
|
||||
|
||||
def parse_ffmpeg_time(line):
|
||||
m = time_pattern.search(line)
|
||||
if m:
|
||||
t = m.group(1)
|
||||
try:
|
||||
h, m, s = t.split(':')
|
||||
return int(h)*3600 + int(m)*60 + float(s)
|
||||
except:
|
||||
return None
|
||||
return None
|
||||
|
||||
# ----------------- FFmpeg 终极防卡命令 -----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
# ========== 输入端终极防卡参数 ==========
|
||||
"-rw_timeout", "15000000", # 15秒读超时直接断开
|
||||
"-reconnect", "1",
|
||||
"-reconnect_at_eof", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "8",
|
||||
"-fflags", "+genpts+discardcorrupt+igndts",
|
||||
"-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "2048", # 加大到2048,彻底防爆
|
||||
"-analyzeduration", "3000000",
|
||||
"-probesize", "3000000",
|
||||
"-i", real_url,
|
||||
|
||||
# ========== 画面处理 ==========
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
# ========== 编码参数 ==========
|
||||
"-c:v", codec_v,
|
||||
"-preset", preset_v,
|
||||
*tune_param,
|
||||
"-b:v", "2500k",
|
||||
"-maxrate", "2500k",
|
||||
"-bufsize", "5000k",
|
||||
"-g", "50",
|
||||
"-keyint_min", "50",
|
||||
"-r", "25",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-bf", "0",
|
||||
|
||||
# ========== 音频 ==========
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
# ========== 低延迟输出 ==========
|
||||
"-flags", "+low_delay+global_header",
|
||||
"-fflags", "+genpts+nobuffer",
|
||||
"-threads", "8",
|
||||
"-f", "flv",
|
||||
youtube_rtmp
|
||||
]
|
||||
|
||||
proc = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 杀掉残留进程
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,正在强制终结...")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
proc.kill()
|
||||
proc.wait(timeout=3)
|
||||
|
||||
current_time = datetime.datetime.now().strftime('%H:%M:%S')
|
||||
print(f"[{current_time}] 正在启动YouTube推流进程 → {stream_title}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
last_frame = 0
|
||||
last_time_sec = 0.0
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
no_output_counter = 0
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if line == '' and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
time.sleep(0.01)
|
||||
no_output_counter += 1
|
||||
|
||||
# 连续60秒完全没输出 = 彻底僵尸
|
||||
if no_output_counter > 6000: # 0.01s × 6000 = 60秒
|
||||
print("【致命】FFmpeg连续60秒无任何输出,判定为彻底僵尸进程,强制重启!")
|
||||
break
|
||||
continue
|
||||
|
||||
no_output_counter = 0
|
||||
line = line.strip()
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
# 解析进度
|
||||
frame_match = frame_pattern.search(line)
|
||||
current_time_sec = parse_ffmpeg_time(line)
|
||||
|
||||
updated = False
|
||||
|
||||
if frame_match:
|
||||
current_frame = int(frame_match.group(1))
|
||||
if current_frame > last_frame:
|
||||
last_frame = current_frame
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
updated = True
|
||||
|
||||
if current_time_sec and current_time_sec > last_time_sec + 0.5:
|
||||
last_time_sec = current_time_sec
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
continue
|
||||
|
||||
# 超过28秒没有任何进度增长 → 判定为假死
|
||||
if time.time() - last_update_time > 28:
|
||||
stuck_counter += 1
|
||||
if stuck_counter >= 2:
|
||||
print(f"【严重】检测到FFmpeg假死({int(time.time() - last_update_time)}秒无进度),强制重启进程!")
|
||||
break
|
||||
|
||||
# 错误关键词快速退出
|
||||
if any(kw in line.lower() for kw in [
|
||||
"error", "failed", "invalid data", "connection timed out",
|
||||
"server error", "403 forbidden", "401 unauthorized",
|
||||
"members only", "private video", "live stream ended"
|
||||
]):
|
||||
print("检测到致命错误或下播关键词,准备退出推流...")
|
||||
time.sleep(4)
|
||||
break
|
||||
|
||||
# 退出内层循环 → 需要重启或结束
|
||||
returncode = proc.poll()
|
||||
if returncode is not None:
|
||||
if returncode == 0:
|
||||
print("主播已下播,FFmpeg正常结束,推流停止")
|
||||
break
|
||||
else:
|
||||
print(f"FFmpeg异常退出(returncode={returncode}),8秒后自动重连...")
|
||||
time.sleep(8)
|
||||
continue
|
||||
else:
|
||||
print("FFmpeg被强制中断,8秒后重新启动...")
|
||||
time.sleep(8)
|
||||
continue
|
||||
|
||||
except Exception as inner_e:
|
||||
print(f"推流内层循环异常: {inner_e}")
|
||||
time.sleep(8)
|
||||
continue
|
||||
|
||||
# ====================== 彻底清理进程和状态 ======================
|
||||
print("正在执行最终清理,确保无僵尸进程...")
|
||||
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,执行kill -9")
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 清理全局状态(一个都不漏)
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
recording_time_list.pop(record_name, None)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并完成全部资源清理\n", color_obj.GREEN)
|
||||
|
||||
except Exception as outer_e:
|
||||
print(f"YouTube推流最外层异常: {outer_e}")
|
||||
# 就算最外层炸了,也要强行清理
|
||||
try:
|
||||
if 'proc' in locals() and proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
color_obj.print_colored(f"[{record_name}] 异常退出但已强制清理完毕\n", color_obj.RED)
|
||||
197
backup/66666
@@ -1,197 +0,0 @@
|
||||
import platform
|
||||
import time
|
||||
import datetime
|
||||
import subprocess
|
||||
|
||||
# ====================== 抖音 → YouTube 转推核心代码(终极稳定版)======================
|
||||
try:
|
||||
# 时间戳和标题设置
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{timestamp_str}"
|
||||
|
||||
# 你的 YouTube 推流密钥(请务必换成自己的!)
|
||||
YT_STREAM_KEY = "x04z-564w-aks7-embw-30y4" # food频道
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- 自动检测并启用 NVENC(有N卡就硬件加速)-----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except Exception as e:
|
||||
print(f"NVENC检测异常: {e},使用CPU编码")
|
||||
|
||||
# ----------------- FFmpeg 推流命令(已极致优化)-----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
"-rw_timeout", "15000000",
|
||||
"-reconnect", "1",
|
||||
"-reconnect_at_eof", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "8",
|
||||
"-fflags", "+genpts+discardcorrupt+igndts",
|
||||
"-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "2048",
|
||||
"-analyzeduration", "3000000",
|
||||
"-probesize", "3000000",
|
||||
"-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_param,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+low_delay+global_header",
|
||||
"-fflags", "+genpts+nobuffer",
|
||||
"-threads", "8",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
proc = None
|
||||
start_time = time.time() # 本次推流开始时间
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 清理上一次残留进程
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try: proc.wait(timeout=6)
|
||||
except: proc.kill()
|
||||
|
||||
print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 启动YouTube推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
# 时间戳属性
|
||||
proc.last_out_ts = time.time()
|
||||
last_frame_time = time.time() # ← 关键:最后一帧时间
|
||||
consecutive_404_count = 0
|
||||
|
||||
# ====================== 核心循环(已加入YouTube救命机制)======================
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if line == '' and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
if line:
|
||||
proc.last_out_ts = time.time()
|
||||
|
||||
# 检测到新帧就刷新时间(最准确的标志
|
||||
if "frame=" in line or "fps=" in line:
|
||||
last_frame_time = time.time()
|
||||
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
line_lower = line.lower()
|
||||
|
||||
# 404 连续计数(8次真下播)
|
||||
is_404 = "404" in line_lower and ("not found" in line_lower or "http @" in line_lower)
|
||||
if is_404:
|
||||
consecutive_404_count += 1
|
||||
print(f"【404计数】第 {consecutive_404_count} 次(连续8次即退出)")
|
||||
if consecutive_404_count >= 8:
|
||||
print("【真下播】连续8次404,直播已结束,停止推流")
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
break
|
||||
# 不清零
|
||||
|
||||
# 致命错误直接退出
|
||||
if any(kw in line_lower for kw in [
|
||||
"403 forbidden", "401 unauthorized", "members only",
|
||||
"private video", "live stream ended", "ingestion error"
|
||||
]):
|
||||
print("【致命错误】检测到封禁/私密/下播,立即停止推流")
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
break
|
||||
|
||||
# YouTube救命机制:12秒无新帧就必须处决!(YouTube最多忍15~20秒)
|
||||
if time.time() - start_time > 25: # 25秒后进入战斗模式
|
||||
if time.time() - last_frame_time > 12: # 12秒没新帧 → 立刻重启
|
||||
print(f"【YouTube救命】已 {time.time()-last_frame_time:.1f}秒无新帧,立即重启FFmpeg避免直播被杀!")
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
break
|
||||
|
||||
# 双保险:20秒完全没日志输出也处决
|
||||
if time.time() - start_time > 20:
|
||||
if time.time() - proc.last_out_ts > 20:
|
||||
print("【真死流】20秒无任何输出,强制重启进程")
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
break
|
||||
|
||||
# 进程退出判断
|
||||
returncode = proc.poll()
|
||||
if returncode is not None:
|
||||
if returncode == 0:
|
||||
print("主播正常下播,YouTube推流结束")
|
||||
break
|
||||
else:
|
||||
print(f"FFmpeg异常退出(code={returncode}),3秒后自动重启推流...")
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"推流过程中出现异常: {e}")
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
# ====================== 彻底清理 ======================
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,执行kill")
|
||||
proc.kill()
|
||||
|
||||
try: recording.discard(record_name)
|
||||
except: pass
|
||||
try: recording_time_list.pop(record_name, None)
|
||||
except: pass
|
||||
try:
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
except: pass
|
||||
try: clear_record_info(record_name, record_url)
|
||||
except: pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
|
||||
except Exception as e:
|
||||
print(f"推流最外层异常: {e}")
|
||||
try:
|
||||
if 'proc' in locals() and proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
except: pass
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except: pass
|
||||
color_obj.print_colored(f"[{record_name}] 异常退出但已清理完毕\n", color_obj.RED)
|
||||
203
backup/7777
@@ -1,203 +0,0 @@
|
||||
else:
|
||||
# ====================== 抖音→YouTube 2025(终极精简版) ======================
|
||||
import queue
|
||||
import time
|
||||
import datetime
|
||||
import subprocess
|
||||
import threading
|
||||
import unicodedata
|
||||
import platform
|
||||
import re
|
||||
import traceback
|
||||
|
||||
# --------------------- 配置区(可外部传入) ---------------------
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
YOUTUBE_RTMP = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
MAX_RETRY_DELAY = 90
|
||||
INITIAL_RETRY_DELAY = 3
|
||||
NO_FRAME_TIMEOUT = 120 # 超过多久无新帧视为下播
|
||||
START_CHECK_AFTER = 25.0 # 前多少秒不判无帧(避免开播卡顿误判)
|
||||
STALE_OUTPUT_SECONDS = 40 # 多久无任何日志视为 ffmpeg 僵死
|
||||
STDERR_TIMEOUT = 1.0
|
||||
|
||||
END_KEYWORDS = [
|
||||
"connection reset by peer", "failed to write trailer", "invalid data found",
|
||||
"server returned 403", "server returned 404", "ingestion error", "access denied",
|
||||
"packet too short", "client disconnected", "live has ended", "no route to host",
|
||||
"connection timed out", "connection refused"
|
||||
]
|
||||
|
||||
DOUYIN_HEADERS = (
|
||||
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
|
||||
"Referer: https://live.douyin.com/\r\n"
|
||||
"Origin: https://live.douyin.com\r\n"
|
||||
)
|
||||
|
||||
# --------------------- 工具函数 ---------------------
|
||||
def sanitize_title(text: str, max_len: int = 120) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = ''.join(c for c in text if unicodedata.category(c)[0] != 'C')
|
||||
text = ''.join(c for c in text if ord(c) <= 0xFFFF)
|
||||
return text.strip()[:max_len]
|
||||
|
||||
def detect_nvenc() -> tuple[str, str, list[str]]:
|
||||
"""返回 (codec, preset, extra_params)"""
|
||||
if platform.system().lower() not in ("windows", "linux"):
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
|
||||
try:
|
||||
if subprocess.run(["nvidia-smi"], capture_output=True, timeout=5).returncode != 0:
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=8
|
||||
)
|
||||
if "h264_nvenc" in result.stdout:
|
||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
return "h264_nvenc", "p5", ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
|
||||
except Exception as e:
|
||||
print(f"[WARN] NVENC 检测失败,回落软件编码: {e}")
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
|
||||
def build_ffmpeg_command(real_url: str):
|
||||
codec_v, preset_v, tune_params = detect_nvenc()
|
||||
return [
|
||||
"ffmpeg", "-y", "-loglevel", "info",
|
||||
"-rw_timeout", "15000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "15",
|
||||
"-fflags", "+genpts+discardcorrupt", "-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "8192",
|
||||
"-headers", DOUYIN_HEADERS,
|
||||
"-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_params,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+global_header",
|
||||
"-f", "flv", YOUTUBE_RTMP
|
||||
]
|
||||
|
||||
# --------------------- 标题生成 ---------------------
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
clean_title = sanitize_title(title_in_name)
|
||||
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# --------------------- 主逻辑(单层循环 + 单一 finally) ---------------------
|
||||
proc = None
|
||||
reader_thread = None
|
||||
should_exit = False # 替代 StreamEnded 异常
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
|
||||
def cleanup_resources():
|
||||
"""统一清理(只调用一次)"""
|
||||
nonlocal proc, reader_thread
|
||||
if proc and proc.poll() is None:
|
||||
try: proc.terminate(); proc.wait(timeout=5)
|
||||
except: proc.kill()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=1)
|
||||
|
||||
# 全局状态清理(只执行一次)
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
global monitoring
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except: pass
|
||||
|
||||
try:
|
||||
ffmpeg_cmd = build_ffmpeg_command(real_url)
|
||||
|
||||
while not should_exit:
|
||||
if retry_delay > INITIAL_RETRY_DELAY:
|
||||
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接...")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
# 清理上一次的残留
|
||||
cleanup_resources()
|
||||
proc = None
|
||||
reader_thread = None
|
||||
|
||||
stderr_q = queue.Queue()
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
reader_thread = threading.Thread(
|
||||
target=lambda p=proc, q=stderr_q: (
|
||||
[q.put(l) for l in iter(p.stderr.readline, "")] or q.put(None)
|
||||
)
|
||||
)
|
||||
reader_thread.daemon = True
|
||||
reader_thread.start()
|
||||
|
||||
start_time = last_frame_time = last_log_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = stderr_q.get(timeout=STDERR_TIMEOUT)
|
||||
except queue.Empty:
|
||||
# 超时检查僵死
|
||||
if time.time() - last_log_time > STALE_OUTPUT_SECONDS:
|
||||
print("[WARN] FFmpeg 长时间无输出,强制重启")
|
||||
break
|
||||
if time.time() - start_time > START_CHECK_AFTER and time.time() - last_frame_time > NO_FRAME_TIMEOUT:
|
||||
print(f"[INFO] 连续 {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播")
|
||||
should_exit = True
|
||||
break
|
||||
continue
|
||||
|
||||
if line is None: # stderr 关闭
|
||||
break
|
||||
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
last_log_time = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
|
||||
lower = line.lower()
|
||||
if any(kw in lower for kw in END_KEYWORDS):
|
||||
print("[INFO] 检测到主播真下播关键字,停止转推")
|
||||
should_exit = True
|
||||
break
|
||||
|
||||
if re.search(r'frame=\s*\d+', lower) or "fps=" in lower:
|
||||
last_frame_time = time.time()
|
||||
|
||||
# 内层循环退出:判断是否真下播
|
||||
if should_exit:
|
||||
break
|
||||
|
||||
# 网络波动等情况 → 指数退避重试
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
|
||||
# --------------------- 正常退出 ---------------------
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FATAL] YouTube 推流意外崩溃: {e}")
|
||||
traceback.print_exc()
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流异常退出但已清理\n", color_obj.RED)
|
||||
|
||||
finally:
|
||||
# 无论如何只清理一次
|
||||
cleanup_resources()
|
||||
return
|
||||
19
backup/888
@@ -1,19 +0,0 @@
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-loglevel", "info",
|
||||
"-rw_timeout", "15000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "15",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "8192",
|
||||
"-headers", douyin_headers,
|
||||
"-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_param,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+global_header",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
111
backup/wending
@@ -1,111 +0,0 @@
|
||||
|
||||
try:
|
||||
if split_video_by_time:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
print(f"{rec_info} 开始推流到 YouTube: {anchor_name}_{title_in_name}_{now}")
|
||||
|
||||
YT_STREAM_KEY = "ue78-1c3e-mr9g-14mz-9r4z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
# ------------------------
|
||||
# NVENC 检测
|
||||
# ------------------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except:
|
||||
pass # 异常自动降级
|
||||
|
||||
# ------------------------
|
||||
# FFmpeg 命令(稳定推流)
|
||||
# ------------------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
"-re",
|
||||
"-i", real_url,
|
||||
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
"-c:v", codec_v,
|
||||
"-preset", preset_v,
|
||||
*tune_param,
|
||||
|
||||
"-b:v", "2500k",
|
||||
"-maxrate", "2500k",
|
||||
"-bufsize", "5000k",
|
||||
"-g", "50",
|
||||
"-r", "25",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-bf", "0",
|
||||
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
"-flags", "+low_delay",
|
||||
"-fflags", "+genpts",
|
||||
"-thread_queue_size", "512",
|
||||
"-threads", "6",
|
||||
|
||||
"-f", "flv",
|
||||
youtube_rtmp
|
||||
]
|
||||
|
||||
# ------------------------
|
||||
# 永不断流重连逻辑
|
||||
# ------------------------
|
||||
proc = None
|
||||
while True:
|
||||
try:
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
print(f"{anchor_name}_{title_in_name}_{now} → 启动 YouTube 推流...")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
# 打印 FFmpeg 错误日志,便于排查
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if line:
|
||||
print(f"FFmpeg: {line}")
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
code = proc.returncode
|
||||
if code != 0:
|
||||
print(f"FFmpeg 退出(code={code}),5秒后重连...")
|
||||
time.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
print(f"启动 FFmpeg 异常: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
print(f"外层异常: {e}")
|
||||
@@ -1,30 +1,39 @@
|
||||
https://live.douyin.com/9638535297,主播: 异度世界
|
||||
https://live.douyin.com/372551154040,主播: 向嘉_手打柠檬茶
|
||||
https://live.douyin.com/203476627795,主播: 蜜桃姐姐(手打柠檬茶)
|
||||
https://live.douyin.com/825627287092,主播: 杏绘茶档_(00后创业版_手打柠檬茶)
|
||||
https://live.douyin.com/147650297461,主播: 娱乐摆摊记
|
||||
https://live.douyin.com/806666144080,主播: 卢荟胶
|
||||
https://live.douyin.com/589958309143,主播: 大萝卜
|
||||
https://live.douyin.com/35932729226,主播: 内向小学生
|
||||
https://live.douyin.com/456576000931,主播: 介休〖烩饕〗大块牛肉饭「摆摊」
|
||||
https://live.douyin.com/782322393954,主播: 小葵(摆摊版)
|
||||
https://live.douyin.com/208318182053,主播: 小碗(创业版)
|
||||
https://live.douyin.com/646935372639,主播: 小雪摆摊记
|
||||
https://live.douyin.com/956877163856,主播: 饭饱饱
|
||||
https://live.douyin.com/881451071780,主播: YINING一柠手打柠檬茶
|
||||
https://live.douyin.com/270513395244,主播: 小喻的摆摊日记
|
||||
https://live.douyin.com/742474761291,主播: 阿诺椰子•
|
||||
https://live.douyin.com/820845121901,主播: 地摊闺蜜_创意寿司_(马儿努力版)
|
||||
https://live.douyin.com/980855021360,主播: 图爸摆摊卖枣糕(日照首家)教学员
|
||||
https://live.douyin.com/46238285118,主播: 我叫小彤呀
|
||||
https://live.douyin.com/497155982953,主播: 想吃糖葫芦
|
||||
https://live.douyin.com/349973241990,主播: 元气满满饭团(摆摊不摆烂版)
|
||||
https://live.douyin.com/814839174401,主播: 飞飞摆摊不摆烂
|
||||
https://live.douyin.com/597618993066,主播: 婷婷的茶生活
|
||||
https://live.douyin.com/16531926455,主播: 木黎黎
|
||||
https://live.douyin.com/739670432305,主播: 干饭007
|
||||
https://live.douyin.com/486789870173,主播: 凉皮西施—豆豆
|
||||
https://live.douyin.com/15652099787,主播: 莹莹在摆摊
|
||||
https://live.douyin.com/572442812068,主播: 小小
|
||||
https://live.douyin.com/477961427964,主播: 小鱼
|
||||
https://live.douyin.com/686507122231,主播: 上海少妇鲜果茶
|
||||
https://live.douyin.com/925673220466,主播: 兜有米麻糍
|
||||
https://live.douyin.com/745606325880,主播: w、(摆摊日记_)
|
||||
https://live.douyin.com/675343045974,主播: 张子沐的串串(你的电子榨菜)
|
||||
https://live.douyin.com/673565298571,主播: 轻舟已撞大冰山(摆摊vlog)
|
||||
https://live.douyin.com/835571459859,主播: 海绵饱饱
|
||||
https://live.douyin.com/152358755212,主播: 大美_红烧肉
|
||||
https://live.douyin.com/990825651731,主播: 王同学摆摊日记(蜜汁烤鸡腿)
|
||||
https://live.douyin.com/591442402624,主播: 76696515302
|
||||
https://live.douyin.com/967381081829,主播: 憨憨的摆摊日记
|
||||
https://live.douyin.com/78012762575,主播: 摆摊儿去划水
|
||||
https://live.douyin.com/163813589919,主播: 不要熬夜(摆摊中)
|
||||
https://live.douyin.com/498368994814,主播: 剪头发的小帆帆
|
||||
https://live.douyin.com/617848099204,主播: 果妈要努力
|
||||
https://live.douyin.com/821525628944,主播: 李熙雨⭐
|
||||
https://live.douyin.com/993102287144,主播: 小茗早餐(薪笼记助创官)
|
||||
https://live.douyin.com/701547125568,主播: 悠悠包
|
||||
https://live.douyin.com/816130992220,主播: 甜昕冰冰的摆摊日记
|
||||
https://live.douyin.com/876468215361,主播: 晓晓和凡凡
|
||||
https://live.douyin.com/511335278313,主播: 秘书(摆摊休息版)
|
||||
https://live.douyin.com/483160615952,主播: 小胡同学你好(饭团版)
|
||||
https://live.douyin.com/525514386431,主播: 阿闯烤肉夹馍
|
||||
https://live.douyin.com/8687122573,主播: 小彤炒粉
|
||||
https://live.douyin.com/642534242822,主播: (小王_)煎饼果子
|
||||
https://live.douyin.com/743565594721,主播: 汪汪汪小妞
|
||||
https://live.douyin.com/249578288248,主播: 思思努力版
|
||||
https://live.douyin.com/472140253414,主播: 戴盘龙
|
||||
https://live.douyin.com/700846653732,主播: 三哥大锅菜_炒鸡(国基路店)官方号
|
||||
https://live.douyin.com/81849868631,主播: 纯儿²¹(休息勿跑空哦)
|
||||
https://live.douyin.com/690114366322,主播: 卤味鲜(东大)
|
||||
https://live.douyin.com/469980190666,主播: 茜茜柠檬茶(小米茶档_)
|
||||
https://live.douyin.com/388066418744,主播: 加速姐的摆摊日记
|
||||
https://live.douyin.com/661694696687,主播: 小米摆摊记
|
||||
https://live.douyin.com/228132412943,主播: 00后摆摊小可爱
|
||||
https://live.douyin.com/876677865786,主播: 牛哄哄
|
||||
https://live.douyin.com/573735790640,主播: 小董卤串(餐饮)
|
||||
https://live.douyin.com/260608582882,主播: 阿伟在炒饭(90後炒饭)
|
||||
https://live.douyin.com/210443559964,主播: 小小诺
|
||||
https://live.douyin.com/305363702471,主播: 嘉嘉在摆摊
|
||||
https://live.douyin.com/143239713951,主播: ɞ_大馋丫头的烤肠
|
||||
5
config/easytier_network.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"networkName": "lr_f976289ba590",
|
||||
"networkSecret": "5f86ed227e8cfcf7d8b70b287502b682aa8d3ca0b0c560ed",
|
||||
"peerUrls": []
|
||||
}
|
||||
10
config/google_oauth.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"redirect_uri": "http://127.0.0.1:8001/youtube/oauth/callback",
|
||||
"web": {
|
||||
"client_id": "305371487651-iuqafultpqp0rvitojr70560k1foet0n.apps.googleusercontent.com",
|
||||
"client_secret": "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",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"redirect_uris": ["http://127.0.0.1:8001/youtube/oauth/callback"]
|
||||
}
|
||||
}
|
||||
11
config/runtime.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# 复制本文件为同目录下的 runtime.env 后生效(已加入 .gitignore,不会提交)
|
||||
# 启动器 scripts/launch.py 会自动加载 runtime.env 中的键(不覆盖已存在的环境变量)
|
||||
|
||||
# 局域网内任意浏览器访问控制台时,请保持 0.0.0.0
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Web 控制台(FastAPI + 静态页)端口;与同机 SRS / Clash / Neko 等冲突时请改成未占用端口,例如 8101、9200
|
||||
PORT=8001
|
||||
|
||||
# 开发模式时 Next 端口(一般无需改)
|
||||
# NEXT_DEV_PORT=3000
|
||||
1
config/web2_api_token.txt
Normal file
@@ -0,0 +1 @@
|
||||
9d0103f49a3fea59311c014b9468e3ad2bd14d4f8334a7ec
|
||||
1
config/web2_bind_host.txt
Normal file
@@ -0,0 +1 @@
|
||||
0.0.0.0
|
||||
13
config/youtube.ini
Normal file
@@ -0,0 +1,13 @@
|
||||
[youtube]
|
||||
# 必填: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
|
||||
6
dev.bat
Normal file
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
if not defined PORT set "PORT=8001"
|
||||
echo [dev] API+Next 开发模式 PORT=%PORT%
|
||||
echo [dev] 浏览器打开 http://localhost:3000
|
||||
python scripts\launch.py --dev --port %PORT%
|
||||
8
dev.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# 开发:API(热重载) + Next dev;浏览器打开 http://localhost:3000
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
export PORT="${PORT:-8001}"
|
||||
PY=python3
|
||||
command -v python3 >/dev/null 2>&1 || PY=python
|
||||
exec "$PY" scripts/launch.py --dev --port "$PORT"
|
||||
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
# 控制台默认 8101(与 SRS 8080/1985、Clash 7890 等错开);改端口请同步修改左侧宿主机映射
|
||||
services:
|
||||
live-console:
|
||||
build: .
|
||||
ports:
|
||||
- "${LIVE_CONSOLE_PORT:-8101}:8101"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "8101"
|
||||
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
@@ -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 是否有效
|
||||
368
douyin_youtube_ffplay.py
Normal file
@@ -0,0 +1,368 @@
|
||||
# douyin_youtube_ffplay.py
|
||||
import queue
|
||||
import unicodedata
|
||||
import platform
|
||||
import traceback
|
||||
import time
|
||||
import datetime
|
||||
import subprocess
|
||||
import threading
|
||||
import re
|
||||
import os
|
||||
import configparser
|
||||
|
||||
|
||||
class StreamEnded(Exception):
|
||||
"""主播真实下播的专用异常"""
|
||||
pass
|
||||
|
||||
|
||||
def sanitize_title(text: str, max_len: int = 120) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C')
|
||||
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
|
||||
return text.strip()[:max_len]
|
||||
|
||||
|
||||
def _stderr_reader_thread(proc, q):
|
||||
try:
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
q.put(line)
|
||||
except Exception as e:
|
||||
try:
|
||||
q.put(f"__ERR__READER__:{e}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
q.put(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cleanup_proc(proc):
|
||||
try:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_douyin_youtube_ffplay(
|
||||
title: str,
|
||||
anchor_name: str,
|
||||
real_url: str,
|
||||
rec_info: str,
|
||||
record_name: str,
|
||||
recording: set,
|
||||
recording_time_list: dict,
|
||||
running_list: list,
|
||||
monitoring: list,
|
||||
clear_record_info,
|
||||
color_obj,
|
||||
):
|
||||
"""
|
||||
抖音 → YouTube 推流主函数(单路推流)
|
||||
只从 config/youtube.ini 读取一个 stream key(取第一个有效值)
|
||||
"""
|
||||
|
||||
# ====================== 读取 YouTube 配置 ======================
|
||||
config_file = "config/youtube.ini"
|
||||
rtmps_base = "rtmps://a.rtmp.youtube.com/live2/"
|
||||
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
|
||||
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 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
|
||||
INITIAL_RETRY_DELAY = 3
|
||||
STDERR_QUEUE_TIMEOUT = 1.0
|
||||
NO_FRAME_TIMEOUT = 120
|
||||
START_CHECK_AFTER = 25
|
||||
STALE_OUTPUT_SECONDS = 40
|
||||
RECONNECT_DELAY_MAX = 10
|
||||
|
||||
END_KEYWORDS = [
|
||||
"connection reset by peer",
|
||||
"failed to write trailer",
|
||||
"invalid data found",
|
||||
"server returned 403",
|
||||
"server returned 404",
|
||||
"server returned 500",
|
||||
"ingestion error",
|
||||
"access denied",
|
||||
"packet too short",
|
||||
"client disconnected",
|
||||
"live has ended",
|
||||
"no route to host",
|
||||
"connection timed out",
|
||||
"connection refused",
|
||||
"error writing output",
|
||||
"broken pipe",
|
||||
"i/o error",
|
||||
]
|
||||
|
||||
douyin_headers = (
|
||||
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
|
||||
"Referer: https://live.douyin.com/\r\n"
|
||||
"Origin: https://live.douyin.com\r\n"
|
||||
)
|
||||
|
||||
# ====================== 标题处理 ======================
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
clean_title = sanitize_title(title)
|
||||
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ====================== NVENC 检测(符合 YouTube 推荐比特率)======================
|
||||
codec_v = "libx264"
|
||||
# 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)
|
||||
if check.returncode == 0:
|
||||
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"
|
||||
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}")
|
||||
|
||||
# ====================== 开源音频处理:背景音乐弱化 + 防版权 ======================
|
||||
_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,"
|
||||
|
||||
# 强化版:收紧带通 + 频率偏移 + 音高微调 + 多段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"
|
||||
)
|
||||
|
||||
# 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",
|
||||
"-stats", "-stats_period", "1",
|
||||
"-rw_timeout", "50000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", str(RECONNECT_DELAY_MAX),
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-err_detect", "ignore_err",
|
||||
"-max_delay", "500000",
|
||||
"-thread_queue_size", "2048",
|
||||
"-filter_threads", "4",
|
||||
"-headers", douyin_headers,
|
||||
"-re",
|
||||
"-i", real_url,
|
||||
# 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",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
|
||||
"-af", _af_chain,
|
||||
"-flags", "+global_header",
|
||||
"-flvflags", "no_duration_filesize",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
proc = None
|
||||
stderr_q = None
|
||||
reader_t = None
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if retry_delay > INITIAL_RETRY_DELAY:
|
||||
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接抖音源...")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
cleanup_proc(proc)
|
||||
stderr_q = queue.Queue()
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
_popen_kw = dict(
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
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))
|
||||
reader_t.daemon = True
|
||||
reader_t.start()
|
||||
|
||||
start_time = last_frame_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT)
|
||||
except queue.Empty:
|
||||
if time.time() - getattr(proc, "last_out_ts", time.time()) > STALE_OUTPUT_SECONDS:
|
||||
print("[WARN] 长时间无任何日志,强制重启进程")
|
||||
cleanup_proc(proc)
|
||||
break
|
||||
continue
|
||||
|
||||
if line is None:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
if line.startswith("__ERR__READER__"):
|
||||
print(f"[ERROR] stderr reader 异常: {line}")
|
||||
continue
|
||||
|
||||
if line:
|
||||
proc.last_out_ts = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
line_lower = line.lower()
|
||||
|
||||
if any(kw in line_lower for kw in END_KEYWORDS):
|
||||
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower:
|
||||
last_frame_time = time.time()
|
||||
|
||||
if time.time() - start_time > START_CHECK_AFTER:
|
||||
if time.time() - last_frame_time > NO_FRAME_TIMEOUT:
|
||||
print(f"[WARN] 连续 {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
except StreamEnded:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 内层异常: {e}")
|
||||
traceback.print_exc()
|
||||
cleanup_proc(proc)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
break
|
||||
|
||||
continue
|
||||
|
||||
except StreamEnded:
|
||||
print(f"[INFO] 主播已下播,停止 YouTube 转推 → {record_name}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FATAL] YouTube 推流最外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
cleanup_proc(proc)
|
||||
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if real_url in running_list:
|
||||
running_list.remove(real_url)
|
||||
monitoring[0] = max(0, monitoring[0] - 1)
|
||||
clear_record_info(record_name, real_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
26
ecosystem.config.cjs
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* PM2:在项目根执行 `pm2 start ecosystem.config.cjs` 后 `pm2 save`
|
||||
* web 使用 scripts/launch.py:若缺少 web-console/out 会自动 npm build 再起 uvicorn
|
||||
*/
|
||||
const path = require("path");
|
||||
const root = __dirname;
|
||||
const py = process.env.PYTHON || "python3";
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "web",
|
||||
cwd: root,
|
||||
script: path.join(root, "scripts", "launch.py"),
|
||||
interpreter: py,
|
||||
env: {
|
||||
HOST: "0.0.0.0",
|
||||
PORT: process.env.PORT || "8001",
|
||||
},
|
||||
},
|
||||
// { name: "youtube", cwd: root, script: py, args: "youtube.py" },
|
||||
// { name: "tiktok", cwd: root, script: py, args: "tiktok.py" },
|
||||
// Linux: { name: "obs", cwd: root, script: "bash", args: `-lc ${JSON.stringify(path.join(root, "obs.sh"))}` },
|
||||
// Win: { name: "obs", cwd: root, script: "cmd.exe", args: "/c obs.bat" },
|
||||
],
|
||||
};
|
||||
533
index.html
@@ -1,215 +1,338 @@
|
||||
<!--
|
||||
Project: DouyinLiveRecorder
|
||||
Author: Hmily
|
||||
Build: 2023.08.14 - 20:24:05
|
||||
GitHub Project URL: https://github.com/ihmily/DouyinLiveRecorder
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="referrer" content="never">
|
||||
<title>M3U8 视频播放器</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', Arial, sans-serif;
|
||||
background-color: #1a237e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
background-image: linear-gradient(120deg, #1a237e 0%, #283593 50%, #4a148c 100%);
|
||||
}
|
||||
|
||||
|
||||
.container {
|
||||
max-width: 640px;
|
||||
width: 80%;
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
#videoPlayer {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
position: relative;
|
||||
background-color: #000;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
|
||||
display: none;
|
||||
}
|
||||
|
||||
video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#videoUrlInput{
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
padding: 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #283593;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
margin: 0 0 10px 0;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
#playButton:hover {
|
||||
background-color: #1a237e;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 20px;
|
||||
line-height: 1.4;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
|
||||
display: block;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
p{
|
||||
color: black;
|
||||
}
|
||||
|
||||
a.no_style {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
margin-top:30px;
|
||||
}
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
#videoUrlInput{
|
||||
|
||||
margin-top: 30px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>多进程录制控制台</title>
|
||||
<style>
|
||||
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
|
||||
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
|
||||
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,var(--bg),#283593,#4a148c);color:var(--text);min-height:100vh;display:flex;justify-content:center;padding:20px;box-sizing:border-box}
|
||||
.container{max-width:720px;width:100%;background:var(--card);color:var(--card-text);border-radius:12px;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
|
||||
h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
|
||||
#processSelect{padding:8px;border-radius:8px;border:1px solid #ddd;font-size:1em}
|
||||
#statusIndicator{font-size:1.2em;font-weight:600}
|
||||
h2{margin:24px 0 12px;font-size:1.4em}
|
||||
h3{margin:16px 0 8px;font-size:1.2em}
|
||||
input,button,textarea{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
|
||||
input,textarea{background:#f5f5f5;color:#000;border:1px solid #ddd;font-family:monospace;overflow:hidden;resize:none;min-height:100px}
|
||||
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
|
||||
button:hover{background:#1a237e}
|
||||
button.loading{opacity:.7;pointer-events:none}
|
||||
button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
|
||||
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
|
||||
#pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
|
||||
.configButtons{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin:12px 0}
|
||||
.configStatus{color:#0f0;margin-top:8px;font-family:monospace;font-size:0.9em}
|
||||
#obsAddress{background:#f0f0f0;padding:12px;border-radius:8px;font-family:monospace;font-size:1.1em;margin:8px 0;word-break:break-all}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<input type="text" id="videoUrlInput" placeholder="请输入 M3U8或者FLV 视频链接">
|
||||
<button id="playButton">播放视频</button>
|
||||
<div id="videoPlayer">
|
||||
<video controls></video>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p><strong>说明</strong><p>
|
||||
<p>M3U8文件格式</p>
|
||||
<p>M3U8文件是采用UTF-8编码格式的M3U文件。M3U文件本身是一个纯文本索引文件,其核心功能是记录多媒体文件链接。当用户打开此类文件时,播放软件会根据索引查找相应的音视频文件网络地址,然后进行在线播放。</p>
|
||||
<p>M3U最初设计用于播放音频文件,例如MP3。但随着时间推移,更多的播放器和软件开始使用M3U来播放视频文件列表,同时也支持在线流媒体音频源的指定。目前,许多播放器和软件都兼容M3U文件格式。</p>
|
||||
<p>FLV文件格式(Flash Video Format)是Adobe公司开发的一种专门用于网页视频播放的文件格式。FLV格式的视频文件通常用于播放短视频和在线流媒体,可以嵌入到网页中供用户观看。FLV视频通常由Adobe Flash Player播放器播放,而其他第三方播放器也支持此格式。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2023 <a href='https://github.com/ihmily/DouyinLiveRecorder' class="no_style" target="_blank">Hmily</a>. All rights reserved.</p>
|
||||
</div>
|
||||
<script>
|
||||
function httpToHttps(url) {
|
||||
if (url.startsWith("http://")) {
|
||||
return url.replace("http://", "https://");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
function playVideo() {
|
||||
let videoUrl = document.getElementById('videoUrlInput').value;
|
||||
const video = document.querySelector('#videoPlayer video');
|
||||
const description = document.querySelector('.description');
|
||||
if (videoUrl == ''){
|
||||
alert('请输入视频链接');
|
||||
return;
|
||||
}
|
||||
videoUrl = httpToHttps(videoUrl);
|
||||
if (videoUrl.includes('.m3u8')) {
|
||||
videoPlayer.style.display = 'block';
|
||||
description.style.display = 'none';
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
hls.loadSource(videoUrl);
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = videoUrl;
|
||||
} else {
|
||||
alert('M3U8 格式不受您的浏览器支持。');
|
||||
console.error('M3U8 格式不受您的浏览器支持。');
|
||||
return;
|
||||
}
|
||||
} else if (videoUrl.includes('.flv')) {
|
||||
if (flvjs.isSupported()) {
|
||||
const flvPlayer = flvjs.createPlayer({
|
||||
type: 'flv',
|
||||
url: videoUrl
|
||||
});
|
||||
flvPlayer.attachMediaElement(video);
|
||||
flvPlayer.load();
|
||||
flvPlayer.play();
|
||||
} else {
|
||||
alert('FLV 格式不受您的浏览器支持。');
|
||||
console.error('FLV 格式不受您的浏览器支持。');
|
||||
return;
|
||||
}
|
||||
<div class="container">
|
||||
<h1>
|
||||
进程:
|
||||
<select id="processSelect"></select>
|
||||
<span id="statusIndicator">⚪ 加载中...</span>
|
||||
</h1>
|
||||
<p id="processScriptNote" style="font-size:0.88em;margin:-12px 0 20px;opacity:0.88;font-family:ui-monospace,monospace"></p>
|
||||
|
||||
videoPlayer.style.display = 'block';
|
||||
description.style.display = 'none';
|
||||
} else {
|
||||
console.error('不支持播放该视频格式。');
|
||||
alert('不支持播放该视频格式。');
|
||||
<!-- YouTube 专属配置(仅 youtube 时显示) -->
|
||||
<div id="youtubeOnlySection" style="display:none;">
|
||||
<h2>YouTube 配置编辑 (youtube.ini)</h2>
|
||||
<div class="configEditor">
|
||||
<h3>youtube.ini</h3>
|
||||
<textarea id="youtubeConfig"></textarea>
|
||||
<div class="configButtons">
|
||||
<button id="loadYoutubeBtn">加载配置</button>
|
||||
<button id="saveYoutubeBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="youtubeStatus">就绪</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL_config.ini 共享配置(youtube 或 tiktok 时显示) -->
|
||||
<div id="urlConfigSection" style="display:none;">
|
||||
<h2>URL 配置编辑 (URL_config.ini)</h2>
|
||||
<div class="configEditor">
|
||||
<h3>URL_config.ini</h3>
|
||||
<textarea id="urlConfig"></textarea>
|
||||
<div class="configButtons">
|
||||
<button id="loadUrlBtn">加载配置</button>
|
||||
<button id="saveUrlBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="urlStatus">就绪</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OBS 推流地址区(仅 obs 时显示) -->
|
||||
<div id="obsSection" style="display:none;">
|
||||
<h2>OBS 直播推流地址</h2>
|
||||
<p>请在 OBS → 设置 → 推流 → 服务选择“自定义”,服务器填写以下地址:</p>
|
||||
<div id="obsAddress">srt://live.local:10080/live/obs</div>
|
||||
<p>密钥(Stream Key)留空即可。</p>
|
||||
</div>
|
||||
|
||||
<h2>PM2 进程控制</h2>
|
||||
<div id="pm2Buttons">
|
||||
<button data-action="start">启动</button>
|
||||
<button data-action="stop">停止</button>
|
||||
<button data-action="restart">重启</button>
|
||||
<button data-action="status">手动刷新</button>
|
||||
</div>
|
||||
<h2>实时日志(自动每秒刷新)</h2>
|
||||
<pre id="pm2Log">正在加载状态与日志...</pre>
|
||||
</div>
|
||||
<script>
|
||||
/** 进程列表来自 GET /process_monitor(web.py 扫描根目录);PM2 名 = 脚本主文件名(无扩展名)。 */
|
||||
let PROCESS_MONITOR = [];
|
||||
|
||||
function pm2NameFromScript(script) {
|
||||
const base = String(script).split(/[/\\]/).pop() || '';
|
||||
const i = base.lastIndexOf('.');
|
||||
return i > 0 ? base.slice(0, i) : base;
|
||||
}
|
||||
|
||||
function isYoutubeScript(script) {
|
||||
if (!/\.py$/i.test(script)) return false;
|
||||
var s = pm2NameFromScript(script).toLowerCase();
|
||||
return s.startsWith('youtube') || s.startsWith('douyin_youtube');
|
||||
}
|
||||
function isTiktokScript(script) {
|
||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok');
|
||||
}
|
||||
function isObsScript(script) {
|
||||
return pm2NameFromScript(script).startsWith('obs');
|
||||
}
|
||||
|
||||
async function loadProcessMonitor() {
|
||||
const res = await fetch('/process_monitor');
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = await res.json();
|
||||
const entries = data.entries || [];
|
||||
PROCESS_MONITOR = entries.map((e) => ({
|
||||
script: e.script,
|
||||
label: e.label || e.script,
|
||||
pm2: e.pm2 || pm2NameFromScript(e.script),
|
||||
}));
|
||||
}
|
||||
|
||||
function getEntryByPm2(name) {
|
||||
return PROCESS_MONITOR.find((e) => e.pm2 === name) || null;
|
||||
}
|
||||
|
||||
function fillProcessSelect() {
|
||||
processSelect.innerHTML = '';
|
||||
PROCESS_MONITOR.forEach((e) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = e.pm2;
|
||||
opt.textContent = `${e.label}(${e.script})`;
|
||||
processSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
const logEl = document.getElementById('pm2Log');
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
const processSelect = document.getElementById('processSelect');
|
||||
let currentProcess = '';
|
||||
|
||||
// 各专属区域
|
||||
const youtubeOnlySection = document.getElementById('youtubeOnlySection');
|
||||
const urlConfigSection = document.getElementById('urlConfigSection');
|
||||
const obsSection = document.getElementById('obsSection');
|
||||
|
||||
// youtube.ini 编辑(youtube 专属)
|
||||
const youtubeConfig = document.getElementById('youtubeConfig');
|
||||
const loadYoutubeBtn = document.getElementById('loadYoutubeBtn');
|
||||
const saveYoutubeBtn = document.getElementById('saveYoutubeBtn');
|
||||
const youtubeStatus = document.getElementById('youtubeStatus');
|
||||
|
||||
// URL_config.ini 编辑(youtube 和 tiktok 共享)
|
||||
const urlConfig = document.getElementById('urlConfig');
|
||||
const loadUrlBtn = document.getElementById('loadUrlBtn');
|
||||
const saveUrlBtn = document.getElementById('saveUrlBtn');
|
||||
const urlStatus = document.getElementById('urlStatus');
|
||||
|
||||
function refreshProcessScriptNote() {
|
||||
const el = document.getElementById('processScriptNote');
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
el.textContent = ent ? `脚本: ${ent.script} · PM2 名: ${ent.pm2}` : '';
|
||||
}
|
||||
|
||||
processSelect.addEventListener('change', () => {
|
||||
currentProcess = processSelect.value;
|
||||
refreshProcessScriptNote();
|
||||
fetchStatus();
|
||||
updateSectionsVisibility();
|
||||
});
|
||||
|
||||
function updateSectionsVisibility() {
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
const s = ent ? ent.script : '';
|
||||
const isYoutube = ent && isYoutubeScript(s);
|
||||
const isTiktok = ent && isTiktokScript(s);
|
||||
const isObs = ent && isObsScript(s);
|
||||
|
||||
youtubeOnlySection.style.display = isYoutube ? 'block' : 'none';
|
||||
urlConfigSection.style.display = (isYoutube || isTiktok) ? 'block' : 'none';
|
||||
obsSection.style.display = isObs ? 'block' : 'none';
|
||||
|
||||
if (isYoutube) loadYoutubeConfig();
|
||||
if (isYoutube || isTiktok) loadUrlConfig();
|
||||
}
|
||||
|
||||
// textarea 高度自适应
|
||||
function autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = (textarea.scrollHeight) + 'px';
|
||||
}
|
||||
[youtubeConfig, urlConfig].forEach(ta => {
|
||||
ta.addEventListener('input', () => autoResize(ta));
|
||||
autoResize(ta);
|
||||
});
|
||||
|
||||
// 通用加载/保存
|
||||
async function loadConfig(endpoint, textarea, statusEl) {
|
||||
statusEl.textContent = '加载中...';
|
||||
try {
|
||||
const res = await fetch(`${endpoint}?process=${currentProcess}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
textarea.value = data.content || '';
|
||||
autoResize(textarea);
|
||||
statusEl.textContent = '加载成功';
|
||||
} catch (err) {
|
||||
statusEl.textContent = `加载失败: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(endpoint, textarea, statusEl, saveBtn) {
|
||||
statusEl.textContent = '保存中...';
|
||||
const content = textarea.value;
|
||||
try {
|
||||
const res = await fetch(`${endpoint}?process=${currentProcess}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({content})
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
statusEl.textContent = data.message || '保存成功';
|
||||
} catch (err) {
|
||||
statusEl.textContent = `保存失败: ${err.message}`;
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// youtube.ini(仅 youtube)
|
||||
function loadYoutubeConfig() { loadConfig('/get_config', youtubeConfig, youtubeStatus); }
|
||||
function saveYoutubeConfig() {
|
||||
saveYoutubeBtn.classList.add('loading');
|
||||
saveConfig('/save_config', youtubeConfig, youtubeStatus, saveYoutubeBtn);
|
||||
}
|
||||
loadYoutubeBtn.onclick = loadYoutubeConfig;
|
||||
saveYoutubeBtn.onclick = saveYoutubeConfig;
|
||||
|
||||
// URL_config.ini(youtube 和 tiktok 共享)
|
||||
function loadUrlConfig() { loadConfig('/get_url_config', urlConfig, urlStatus); }
|
||||
function saveUrlConfig() {
|
||||
saveUrlBtn.classList.add('loading');
|
||||
saveConfig('/save_url_config', urlConfig, urlStatus, saveUrlBtn);
|
||||
}
|
||||
loadUrlBtn.onclick = loadUrlConfig;
|
||||
saveUrlBtn.onclick = saveUrlConfig;
|
||||
|
||||
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
|
||||
|
||||
function updateStatus(process_status){
|
||||
let emoji = '⚪', text = '未知';
|
||||
if(process_status === 'online'){ emoji = '🟢'; text = '运行中'; }
|
||||
else if(process_status === 'stopped'){ emoji = '🔴'; text = '已停止'; }
|
||||
else if(process_status === 'errored'){ emoji = '🔴'; text = '错误'; }
|
||||
else if(process_status === 'launching' || process_status === 'starting'){ emoji = '🟡'; text = '启动中'; }
|
||||
else if(process_status === 'waiting restart'){ emoji = '🟡'; text = '等待重启'; }
|
||||
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
|
||||
statusIndicator.innerHTML = `${emoji} ${text}`;
|
||||
}
|
||||
|
||||
async function pm2Action(action){
|
||||
const btn = document.querySelector(`[data-action="${action}"]`);
|
||||
if(btn) btn.classList.add('loading');
|
||||
const url = `/${action}?process=${currentProcess}`;
|
||||
try{
|
||||
const res = await fetch(url);
|
||||
if(!res.ok) throw new Error('网络错误');
|
||||
const data = await res.json();
|
||||
if(action === 'status'){
|
||||
updateStatus(data.process_status);
|
||||
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()})\n${data.raw_status}\n\n`;
|
||||
if(data.process_status === 'not_found'){
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
const hint = ent ? `${ent.script}(PM2 名: ${ent.pm2})` : currentProcess;
|
||||
logText += `【提示】未在 PM2 中找到: ${hint}\n`;
|
||||
}else{
|
||||
logText += `【实时输出日志】`;
|
||||
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
|
||||
logText += `\n${data.recent_log}`;
|
||||
}else{
|
||||
logText += `(最近 ${data.recent_log.trim().split('\n').length} 行)\n${data.recent_log || '无新输出'}`;
|
||||
}
|
||||
logText += `\n\n【实时错误日志】`;
|
||||
if(data.recent_error.includes('不存在') || data.recent_error.includes('无日志路径')){
|
||||
logText += `\n${data.recent_error}`;
|
||||
}else{
|
||||
logText += `(最近 ${data.recent_error.trim().split('\n').length} 行)\n${data.recent_error || '无错误'}`;
|
||||
}
|
||||
}
|
||||
logEl.textContent = logText;
|
||||
scrollLog();
|
||||
}else{
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
const who = ent ? `${ent.script} (${ent.pm2})` : currentProcess;
|
||||
logEl.textContent = `操作: ${action} → ${who}\n${data.output || '成功'}\n\n正在刷新状态...`;
|
||||
scrollLog();
|
||||
setTimeout(fetchStatus, 1500);
|
||||
}
|
||||
}catch(err){
|
||||
logEl.textContent = `请求失败: ${err.message}`;
|
||||
scrollLog();
|
||||
updateStatus('unknown');
|
||||
}finally{
|
||||
if(btn) btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('playButton').addEventListener('click', playVideo);
|
||||
</script>
|
||||
</div>
|
||||
async function fetchStatus(){ pm2Action('status'); }
|
||||
|
||||
// 按钮事件委托
|
||||
document.getElementById('pm2Buttons').addEventListener('click', e=>{
|
||||
const btn = e.target.closest('button');
|
||||
if(!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
if(action && action !== 'status') pm2Action(action);
|
||||
});
|
||||
|
||||
(async function boot() {
|
||||
try {
|
||||
await loadProcessMonitor();
|
||||
} catch (e) {
|
||||
logEl.textContent = '无法加载 /process_monitor(请用本服务打开的页面访问,或检查 web.py / uvicorn 是否已启动)\n' + (e && e.message ? e.message : '');
|
||||
statusIndicator.textContent = '❌ 未连接';
|
||||
return;
|
||||
}
|
||||
if (!PROCESS_MONITOR.length) {
|
||||
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / douyin_youtube*.py / obs 脚本,且含 web.py)';
|
||||
return;
|
||||
}
|
||||
fillProcessSelect();
|
||||
currentProcess = PROCESS_MONITOR[0].pm2;
|
||||
processSelect.value = currentProcess;
|
||||
refreshProcessScriptNote();
|
||||
setInterval(fetchStatus, 1000);
|
||||
fetchStatus();
|
||||
updateSectionsVisibility();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
233
main.py
@@ -1618,224 +1618,25 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
|
||||
with max_request_lock:
|
||||
error_count += 1
|
||||
error_window.append(1)
|
||||
|
||||
|
||||
else:
|
||||
# ====================== 抖音 → SRS → ffplay HDMI 终极低CPU清晰版 ======================
|
||||
import queue
|
||||
import unicodedata
|
||||
import traceback
|
||||
# import time
|
||||
# import datetime
|
||||
# import subprocess
|
||||
# import threading
|
||||
# import re
|
||||
|
||||
class StreamEnded(Exception):
|
||||
pass
|
||||
|
||||
def sanitize_title(text: str, max_len: int = 120) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C')
|
||||
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
|
||||
return text.strip()[:max_len]
|
||||
|
||||
def _stderr_reader_thread(proc, q):
|
||||
try:
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
q.put(line)
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
q.put(None)
|
||||
except:
|
||||
pass
|
||||
|
||||
def cleanup_proc(proc, name="进程"):
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
except:
|
||||
proc.kill()
|
||||
print(f"[INFO] 已强制终止 {name}")
|
||||
|
||||
# ====================== 配置参数 ======================
|
||||
SRS_RTMP_URL = "rtmp://127.0.0.1/live/douyin"
|
||||
SRS_PLAY_URL = "http://127.0.0.1:8080/live/douyin.flv"
|
||||
|
||||
MAX_RETRY_DELAY = 120
|
||||
INITIAL_RETRY_DELAY = 3
|
||||
NO_FRAME_TIMEOUT = 180
|
||||
STALE_OUTPUT_SECONDS = 180
|
||||
FFPLAY_RESTART_INTERVAL = 3600 * 6
|
||||
|
||||
END_KEYWORDS = [
|
||||
"connection reset by peer", "failed to write trailer", "invalid data found",
|
||||
"server returned 403", "server returned 404", "ingestion error",
|
||||
"access denied", "packet too short", "client disconnected",
|
||||
"live has ended", "no route to host", "connection timed out",
|
||||
]
|
||||
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
_clean_title = sanitize_title(title_in_name)
|
||||
stream_title = sanitize_title(f"{anchor_name}{('_' + _clean_title) if _clean_title else ''}_{timestamp_str}")
|
||||
print(f"[INFO] {rec_info} 启动 SRS → ffplay HDMI 转播(低CPU原画版): {stream_title}")
|
||||
|
||||
douyin_headers = (
|
||||
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
|
||||
"Referer: https://live.douyin.com/\r\n"
|
||||
"Origin: https://live.douyin.com\r\n"
|
||||
import sys
|
||||
sys.path.insert(0, './dist')
|
||||
from douyin_srs_ffplay import start_douyin_srs_ffplay
|
||||
# 调用
|
||||
start_douyin_srs_ffplay(
|
||||
title=title_in_name,
|
||||
anchor_name=anchor_name,
|
||||
real_url=real_url,
|
||||
rec_info=rec_info,
|
||||
record_name=record_name,
|
||||
recording=recording,
|
||||
recording_time_list=recording_time_list,
|
||||
running_list=running_list,
|
||||
monitoring=monitoring,
|
||||
clear_record_info=clear_record_info,
|
||||
color_obj=color_obj
|
||||
)
|
||||
|
||||
# ====================== FFmpeg 推流到 SRS(直抄原画,低CPU) ======================
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin",
|
||||
"-headers", douyin_headers,
|
||||
"-rw_timeout", "30000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "7",
|
||||
"-fflags", "+genpts+discardcorrupt+igndts",
|
||||
"-err_detect", "ignore_err",
|
||||
"-i", real_url,
|
||||
"-c:v", "copy", # 直抄视频,CPU极低,画质取决于源(抖音通常给原画)
|
||||
"-c:a", "copy", # 直抄音频
|
||||
"-f", "flv", SRS_RTMP_URL
|
||||
]
|
||||
|
||||
# ====================== ffplay 最佳参数(低CPU + 零漂移 + 全屏) ======================
|
||||
ffplay_cmd = [
|
||||
"ffplay",
|
||||
"-fflags", "nobuffer+genpts",
|
||||
"-flags", "low_delay",
|
||||
"-framedrop",
|
||||
"-sync", "ext", # 零漂移
|
||||
"-vf", "transpose=2", # 旋转
|
||||
"-autoexit",
|
||||
"-fs", # 全屏
|
||||
"-loglevel", "quiet",
|
||||
"-i", SRS_PLAY_URL
|
||||
]
|
||||
|
||||
ffmpeg_proc = None
|
||||
ffplay_proc = None
|
||||
stderr_q = queue.Queue()
|
||||
reader_t = None
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
last_ffplay_restart = time.time()
|
||||
|
||||
try:
|
||||
while True:
|
||||
current_time = time.time()
|
||||
|
||||
if ffplay_proc and (current_time - last_ffplay_restart > FFPLAY_RESTART_INTERVAL):
|
||||
print("[SCHEDULE] 6小时预防性重启 ffplay")
|
||||
cleanup_proc(ffplay_proc, "ffplay")
|
||||
ffplay_proc = None
|
||||
|
||||
if ffplay_proc is None or ffplay_proc.poll() is not None:
|
||||
if ffplay_proc is not None:
|
||||
print("[WARN] ffplay 退出,重启中...")
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 ffplay 播放 SRS 原画流")
|
||||
ffplay_proc = subprocess.Popen(
|
||||
ffplay_cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
time.sleep(2)
|
||||
last_ffplay_restart = time.time()
|
||||
|
||||
if retry_delay > INITIAL_RETRY_DELAY:
|
||||
print(f"[WARN] 等待 {retry_delay}s 后重连抖音源...")
|
||||
time.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
|
||||
cleanup_proc(ffmpeg_proc, "FFmpeg")
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 FFmpeg 直抄推流到 SRS")
|
||||
ffmpeg_proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
ffmpeg_proc.last_out_ts = time.time()
|
||||
|
||||
stderr_q = queue.Queue()
|
||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(ffmpeg_proc, stderr_q))
|
||||
reader_t.daemon = True
|
||||
reader_t.start()
|
||||
|
||||
last_frame_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = stderr_q.get(timeout=1.5)
|
||||
except queue.Empty:
|
||||
if time.time() - ffmpeg_proc.last_out_ts > STALE_OUTPUT_SECONDS:
|
||||
print("[WARN] FFmpeg 僵死,重启")
|
||||
break
|
||||
continue
|
||||
|
||||
if line is None:
|
||||
break
|
||||
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
ffmpeg_proc.last_out_ts = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
line_lower = line.lower()
|
||||
|
||||
if any(kw in line_lower for kw in END_KEYWORDS):
|
||||
print("[INFO] 检测到下播关键字,停止转播")
|
||||
raise StreamEnded()
|
||||
|
||||
if re.search(r'frame=\s*\d+', line_lower):
|
||||
last_frame_time = time.time()
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
|
||||
if time.time() - last_frame_time > NO_FRAME_TIMEOUT:
|
||||
print(f"[INFO] {NO_FRAME_TIMEOUT}s 无新帧,判定下播")
|
||||
raise StreamEnded()
|
||||
|
||||
except StreamEnded:
|
||||
print(f"[INFO] 主播已下播,停止 SRS → ffplay HDMI 转播 → {record_name}")
|
||||
except KeyboardInterrupt:
|
||||
print("[INFO] 用户手动中断")
|
||||
except Exception as e:
|
||||
print(f"[FATAL] 未知异常: {e}")
|
||||
traceback.print_exc()
|
||||
time.sleep(10)
|
||||
finally:
|
||||
cleanup_proc(ffmpeg_proc, "FFmpeg")
|
||||
cleanup_proc(ffplay_proc, "ffplay")
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
global monitoring
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
color_obj.print_colored(f"[{record_name}] SRS → ffplay HDMI 转播已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
|
||||
count_time = time.time()
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 73 KiB |
16
mobile/android/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
182
mobile/android/app/build.gradle
Normal file
@@ -0,0 +1,182 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
*/
|
||||
react {
|
||||
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
hermesCommand = new File(["node", "--print", "require.resolve('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"
|
||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
|
||||
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||
// works correctly with Expo projects.
|
||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||
bundleCommand = "export:embed"
|
||||
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||
// root = file("../../")
|
||||
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||
// reactNativeDir = file("../../node_modules/react-native")
|
||||
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
|
||||
//
|
||||
// The path to the CLI configuration file. Default is empty.
|
||||
// bundleConfig = file(../rn-cli.config.js)
|
||||
//
|
||||
// The name of the generated asset file containing your JS bundle
|
||||
// bundleAssetName = "MyApplication.android.bundle"
|
||||
//
|
||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||
// entryFile = file("../js/MyApplication.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
|
||||
/* Autolinking */
|
||||
autolinkLibrariesWithApp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||
*/
|
||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore (JSC)
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace 'com.douyin.recorder.remote'
|
||||
defaultConfig {
|
||||
applicationId 'com.douyin.recorder.remote'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||
// Trim all elements in place.
|
||||
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||
options -= ""
|
||||
|
||||
if (options.length > 0) {
|
||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||
options.each {
|
||||
android.packagingOptions[prop] += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
if (isGifEnabled) {
|
||||
// For animated gif support
|
||||
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
|
||||
if (isWebpEnabled) {
|
||||
// For webp support
|
||||
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||
if (isWebpAnimatedEnabled) {
|
||||
// Animated webp support
|
||||
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
}
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
BIN
mobile/android/app/debug.keystore
Normal file
14
mobile/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# react-native-reanimated
|
||||
-keep class com.swmansion.reanimated.** { *; }
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# Add any project specific keep options here:
|
||||
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
33
mobile/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="mobile"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.douyin.recorder.remote
|
||||
import expo.modules.splashscreen.SplashScreenManager
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
import expo.modules.ReactActivityDelegateWrapper
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Set the theme to AppTheme BEFORE onCreate to support
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
// setTheme(R.style.AppTheme);
|
||||
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
|
||||
SplashScreenManager.registerOnActivity(this)
|
||||
// @generated end expo-splashscreen
|
||||
super.onCreate(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
*/
|
||||
override fun getMainComponentName(): String = "main"
|
||||
|
||||
/**
|
||||
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||
return ReactActivityDelegateWrapper(
|
||||
this,
|
||||
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||
object : DefaultReactActivityDelegate(
|
||||
this,
|
||||
mainComponentName,
|
||||
fabricEnabled
|
||||
){})
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the back button behavior with Android S
|
||||
* where moving root activities to background instead of finishing activities.
|
||||
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||
*/
|
||||
override fun invokeDefaultOnBackPressed() {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
if (!moveTaskToBack(false)) {
|
||||
// For non-root activities, use the default implementation to finish them.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use the default back button implementation on Android S
|
||||
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.douyin.recorder.remote
|
||||
|
||||
import android.app.Application
|
||||
import android.content.res.Configuration
|
||||
|
||||
import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.common.ReleaseLevel
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||
|
||||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ExpoReactHostFactory
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
override val reactHost: ReactHost by lazy {
|
||||
ExpoReactHostFactory.getDefaultReactHost(
|
||||
context = applicationContext,
|
||||
packageList =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
ReleaseLevel.STABLE
|
||||
}
|
||||
loadReactNative(this)
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,6 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background"/>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||
>
|
||||
|
||||
<selector>
|
||||
<!--
|
||||
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||
|
||||
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
|
||||
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||
-->
|
||||
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||
</selector>
|
||||
|
||||
</inset>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.6 KiB |