14 Commits

Author SHA1 Message Date
eric
4b3c6cbee4 's' 2026-03-28 02:39:29 -05:00
eric
243b9d7acb 's' 2026-03-28 02:31:33 -05:00
eric
58a0cac7db 'codex' 2026-03-28 02:19:40 -05:00
eric
504877a969 's' 2026-03-28 01:27:12 -05:00
eric
cc552d49f0 's' 2026-03-22 23:29:04 -05:00
eric
a4b7edd153 's' 2026-03-22 11:36:22 -05:00
eric
746da3d0b6 s'' 2026-03-22 11:32:24 -05:00
eric
0b2ceb4e8c 's' 2026-03-22 11:27:14 -05:00
eric
587c0e10b4 's' 2026-03-19 23:32:57 -05:00
eric
77969c85fe 's' 2026-03-19 13:46:02 -05:00
eric
f0b1f9cf47 's' 2026-03-19 04:04:23 -05:00
eric
219a8cef61 's' 2026-03-19 03:51:16 -05:00
eric
fd3d952ab8 's' 2026-03-19 03:47:47 -05:00
eric
ab6a6cac65 's' 2026-03-19 03:43:15 -05:00
144 changed files with 10063 additions and 702 deletions

View File

@@ -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

12
.gitignore vendored
View File

@@ -165,3 +165,15 @@ cython_debug/
#.idea/
backup_config/
# 根目录 npmconcurrently 等)
node_modules/
# Next 开发缓存(静态导出目录 web-console/out 可纳入版本库以便无 Node 环境直接部署)
web-console/.next/
# 无 PM2 时的本地进程状态
.process_runner/
# 本机端口/主机覆盖(从 config/runtime.env.example 复制)
config/runtime.env

View File

@@ -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}"]

View File

@@ -1,9 +1,39 @@
https://live.douyin.com/629587976874,主播: 伟利火烧
https://live.douyin.com/606957569518,主播: 李泽宇(煎饼商)
https://live.douyin.com/288000519466,主播: 五花肉肉
https://live.douyin.com/69064125466,主播: 张大丽丽的丽茶努力版
https://live.douyin.com/838061972379,主播: 番茄妹妹
https://live.douyin.com/562629369787,主播: 叹茶柠檬茶
https://live.douyin.com/565208465584,主播: 财圆圆
https://live.douyin.com/51142028798,主播: 小夏妹妹
https://live.douyin.com/974451428277,主播: 雅如柠檬茶
https://live.douyin.com/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,主播: ɞ_大馋丫头的烤肠

View File

@@ -0,0 +1,5 @@
{
"networkName": "lr_f976289ba590",
"networkSecret": "5f86ed227e8cfcf7d8b70b287502b682aa8d3ca0b0c560ed",
"peerUrls": []
}

10
config/google_oauth.json Normal file
View 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"]
}
}

View 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

View File

@@ -0,0 +1 @@
9d0103f49a3fea59311c014b9468e3ad2bd14d4f8334a7ec

View File

@@ -0,0 +1 @@
0.0.0.0

View File

@@ -1,3 +1,13 @@
[youtube]
# 只写 stream key推荐方式
key = x04z-564w-aks7-embw-30y4
# 必填YouTube 直播推流 key在 YouTube 工作室 → 创建 → 流式传输 获取
; key = qxvb-r47b-r5ju-6ud3-6k7z
key = x04z-564w-aks7-embw-30y4
# 可选:是否使用 RTMPS 加密推流,填 否 则用 RTMP默认 RTMPS
; rtmps = 否
# 可选:视频比特率 kbpsYouTube 推荐 2500可填 3000-4500 提高画质
; bitrate = 2500
# 可选:软编 speed 长期小于 1 时开启——轻量音频链(无 arnndn/长 EQ减轻 CPU。填 1 或 是
fast_audio = 1

6
dev.bat Normal file
View 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
View 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
View 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"

View 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× 实时速度**,可满足直播
---
## 三、实现方式
### 方式 APython 自建管道(推荐)
```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. 人声写入 pipeFFmpeg 另一进程读取并与视频复用推流
```
**依赖**
```
demucs
torch
torchaudio
```
**模型加载**
```python
from demucs.pretrained import get_model
model = get_model('htdemucs') # 单模型,非 htdemucs_ft
```
### 方式 BGStreamer若可用
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` 纯 FFmpeg60-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
View 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 字影响搜索)
- 首段包含主关键词和内容概要
- 可说明:转播自抖音、直播内容类型、时间等
### 标签
- 515 个相关标签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 是否有效

View File

@@ -79,25 +79,42 @@ def start_douyin_youtube_ffplay(
只从 config/youtube.ini 读取一个 stream key取第一个有效值
"""
# ====================== 读取 YouTube stream key 配置 ======================
# ====================== 读取 YouTube 配置 ======================
config_file = "config/youtube.ini"
rtmps_base = "rtmps://a.rtmp.youtube.com/live2/"
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
youtube_rtmp = rtmp_base + default_key # 默认值
use_rtmps = True
# 720p 竖屏 YouTube 建议 46 Mbps偏低易触发「数据不足/卡顿」提示
b_v, maxrate, bufsize = "4000k", "4500k", "8000k"
stream_key = None
fast_audio = False # config: fast_audio=1 可跳过 arnndn+长 EQ显著减轻 CPU
if os.path.exists(config_file):
config = configparser.ConfigParser()
config.read(config_file, encoding="utf-8")
if config.has_section("youtube"):
for key_name, value in config.items("youtube"):
value = value.strip()
if value:
if value.startswith("rtmp://"):
youtube_rtmp = value
else:
youtube_rtmp = rtmp_base + value
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
break # 只取第一个有效 key保持单路推流不加多路逻辑
for k, v in config.items("youtube"):
v = (v or "").strip()
if k == "rtmps" and v.lower() in ("0", "false", ""):
use_rtmps = False
elif k == "fast_audio" and v.lower() in ("1", "true", "yes", "", "", "on"):
fast_audio = True
elif k == "bitrate" and v:
try:
n = int(v)
b_v, maxrate, bufsize = f"{n}k", f"{min(n * 12 // 10, 8000)}k", f"{n * 2}k"
except ValueError:
pass
elif (k == "key" or k == "stream_key") and v and not v.startswith("#"):
stream_key = v
if stream_key:
if stream_key.startswith("rtmp://") or stream_key.startswith("rtmps://"):
youtube_rtmp = stream_key
else:
youtube_rtmp = (rtmps_base if use_rtmps else rtmp_base) + stream_key
print(f"[INFO] 从 {config_file} 读取 YouTube 推流地址")
else:
raise ValueError(f"请在 {config_file} 中配置 key=你的stream_key")
# ====================== 常量配置 ======================
MAX_RETRY_DELAY = 90
@@ -106,6 +123,7 @@ def start_douyin_youtube_ffplay(
NO_FRAME_TIMEOUT = 120
START_CHECK_AFTER = 25
STALE_OUTPUT_SECONDS = 40
RECONNECT_DELAY_MAX = 10
END_KEYWORDS = [
"connection reset by peer",
@@ -113,6 +131,7 @@ def start_douyin_youtube_ffplay(
"invalid data found",
"server returned 403",
"server returned 404",
"server returned 500",
"ingestion error",
"access denied",
"packet too short",
@@ -120,6 +139,10 @@ def start_douyin_youtube_ffplay(
"live has ended",
"no route to host",
"connection timed out",
"connection refused",
"error writing output",
"broken pipe",
"i/o error",
]
douyin_headers = (
@@ -134,10 +157,11 @@ def start_douyin_youtube_ffplay(
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
# ====================== NVENC 检测 ======================
# ====================== NVENC 检测(符合 YouTube 推荐比特率)======================
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
# ultrafast+zerolatency在软编下尽量让 speed≥1.0;画质略降、直播可接受
video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0",
"-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
@@ -145,280 +169,90 @@ def start_douyin_youtube_ffplay(
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
if "h264_nvenc" in (enc.stdout or ""):
codec_v = "h264_nvenc"
preset_v = "p5"
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
video_args = ["-profile:v", "high", "-level", "4.0",
"-tune", "ll", "-rc", "cbr", "-b:v", b_v,
"-maxrate", maxrate, "-bufsize", bufsize]
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except Exception as e:
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
# 720p (最原始)
# ====================== 开源音频处理:背景音乐弱化 + 防版权 ======================
_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),无模型则用 afftdnaphaser 相位扰动可干扰 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 推荐46 Mbps、2s 关键帧、AAC 128k
_video_mid = ["-vsync", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
"-bf", "0", "-sc_threshold", "0"]
if codec_v == "libx264":
_video_mid += ["-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0"]
# -re按源时间戳节奏拉流避免突发过快不用 realtime/arealtime输入抖动时易让 YouTube 判为数据不足)
ffmpeg_command = [
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
"ffmpeg", "-y", "-loglevel", "info", "-nostdin",
"-stats", "-stats_period", "1",
"-rw_timeout", "30000000",
"-rw_timeout", "50000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
"-flags", "low_delay",
"-reconnect_delay_max", str(RECONNECT_DELAY_MAX),
"-fflags", "+genpts+discardcorrupt",
"-err_detect", "ignore_err",
"-max_delay", "80000",
"-max_delay", "500000",
"-thread_queue_size", "2048",
"-filter_threads", "4",
"-headers", douyin_headers,
"-re",
"-i", real_url,
# 720p
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", "libx264",
"-preset", "fast",
# "-tune", "zerolatency",
"-profile:v", "high",
# 720p
# "-minrate", "3200k","-b:v", "3200k", "-maxrate", "3200k", "-bufsize", "6400k", # bufsize = 2× bitrate保持真 CBR
"-b:v", "2600k","-maxrate", "3000k","-bufsize", "5200k",
"-vsync", "cfr",
"-g", "60", "-keyint_min", "60",
"-r", "30",
"-bf", "0",
"-sc_threshold", "0",
# "-nal-hrd", "cbr", # 改2强制 CBR
# 720p
# "-x264-params", "nal-hrd=cbr:force-cfr=1:scenecut=0:filler=1", # 改3简化 params锁死 CBR
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
# 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", # 改41080p可音频上 128kYouTube 推荐)
"-af", "aresample=async=1:first_pts=0",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
"-af", _af_chain,
"-flags", "+global_header",
"-flvflags", "no_duration_filesize",
"-f", "flv", youtube_rtmp
]
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," # 人声增强
# 720p-低CPU + 背景音乐处理 + 防ContentID
# ffmpeg_command = [
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
# "-stats", "-stats_period", "1",
# "-rw_timeout", "30000000",
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
# "-reconnect_delay_max", "5",
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
# "-flags", "low_delay",
# "-err_detect", "ignore_err",
# "-max_delay", "80000",
# "-thread_queue_size", "2048",
# "-headers", douyin_headers,
# "-i", real_url,
# # 视频处理 720p
# "-vf",
# "fps=25,scale=720:1280:force_original_aspect_ratio=decrease:flags=bicubic,"
# "pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
# "-c:v", "libx264",
# "-preset", "veryfast", # 降低CPU
# "-profile:v", "high",
# "-b:v", "2600k", "-maxrate", "3000k", "-bufsize", "5200k",
# "-vsync", "cfr",
# "-g", "50", "-keyint_min", "50", # 与fps=25匹配
# "-r", "25",
# "-bf", "0",
# "-sc_threshold", "0",
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
# "-pix_fmt", "yuv420p",
# # 音频处理(轻量 + 背景音乐弱化 + 防Content ID
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
# "-af",
# "afftdn=nf=-28:tn=1," # 轻 FFT 降噪,背景弱化
# "highpass=f=120,lowpass=f=4800," # 切低频鼓 & 高频镲
# "equalizer=f=250:width_type=o:width=2:g=-8," # 衰减低中频
# "equalizer=f=800:width_type=o:width=1.5:g=-5," # 衰减中频
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=100:makeup=4," # 压动态
# "asetrate=48000*1.02,aresample=48000," # 音高微升
# "afreqshift=shift=15," # 小幅频率偏移
# "volume=1.1," # 音量补偿
# "aresample=async=1:first_pts=0", # 音视频同步
# # 推流参数
# "-flvflags", "no_duration_filesize",
# "-f", "flv", youtube_rtmp
# ]
# 1080p
# ffmpeg_command = [
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
# "-stats", "-stats_period", "1",
# "-rw_timeout", "30000000",
# "-reconnect", "1", "-reconnect_at_eof", "1",
# "-reconnect_streamed", "1",
# "-reconnect_delay_max", "5",
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
# "-flags", "low_delay",
# "-err_detect", "ignore_err",
# "-max_delay", "80000",
# "-thread_queue_size", "2048",
# "-headers", douyin_headers,
# "-i", real_url,
# # 视频:不动你原策略,只给编码喘口气
# "-vf", "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease:flags=bicubic,"
# "pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
# "-c:v", "libx264",
# "-preset", "superfast",
# "-tune", "zerolatency",
# "-profile:v", "high",
# "-b:v", "5200k", "-maxrate", "5800k", "-bufsize", "10400k",
# "-vsync", "cfr",
# "-g", "50", "-keyint_min", "50",
# "-r", "25",
# "-bf", "0",
# "-sc_threshold", "0",
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
# "-pix_fmt", "yuv420p",
# # 音频
# "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
# "-af",
# "afftdn=nf=-18:tn=1,"
# "highpass=f=120,lowpass=f=4600,"
# "equalizer=f=250:width_type=o:width=2:g=-6,"
# "equalizer=f=900:width_type=o:width=1.4:g=-4,"
# "asetrate=48000*1.012,aresample=48000,"
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=120:makeup=4,"
# "volume=1.10,"
# "aresample=async=1:first_pts=0",
# "-flvflags", "no_duration_filesize",
# "-f", "flv", youtube_rtmp
# ]
# 1080p - 极致优化版本Ubuntu Server 专用,高质量 + YouTube直播推荐友好 + 强防Content ID
# 针对Ubuntu server通常CPU较强无iOS硬件限制
# - preset 升级到 medium 或 slow如果CPU能承受质量显著提升
# - 比特率保持8500k-10000k峰值符合YouTube 1080p直播高质量推荐
# - 音频防Content ID音高+5%、频率偏移、多段EQ衰减背景音乐、强压缩、相位扰动使用aphaser正确参数、轻噪门
# - 移除无效参数修复之前aphaser错误aphaser支持in_gain/out_gain/delay/decay/speed/type不支持feedback
# - Ubuntu下推荐使用最新FFmpegapt install ffmpeg 或从源码编译支持libx264
# ffmpeg_command = [
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
# "-stats", "-stats_period", "1",
# "-rw_timeout", "30000000",
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
# "-reconnect_delay_max", "5",
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
# "-flags", "low_delay",
# "-err_detect", "ignore_err",
# "-max_delay", "50000",
# "-thread_queue_size", "4096",
# "-headers", douyin_headers,
# "-i", real_url,
# # 视频处理1080p 纵屏优化,黑边填充
# "-vf",
# "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease:flags=bicubic,"
# "pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
# "-c:v", "libx264",
# "-preset", "ultrafast", # Ubuntu server CPU通常能承受质量比faster好很多若掉帧可回退到faster或veryfast
# "-threads", "4",
# "-tune", "zerolatency",
# "-profile:v", "high",
# "-level", "4.2", # 支持1080p@25fps
# # "-b:v", "8500k", "-maxrate", "10000k", "-bufsize", "20000k",
# "-b:v", "4500k", "-maxrate", "5500k", "-bufsize", "9000k",
# "-vsync", "cfr",
# "-g", "50", "-keyint_min", "50",
# "-r", "25",
# "-bf", "0",
# "-sc_threshold", "0",
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0:rc-lookahead=0",
# "-pix_fmt", "yuv420p",
# # 音频处理 - 防Content ID 强化版(多维度修改音频指纹)
# "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2",
# "-af",
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," #神经网络
# "afftdn=nf=-24:tn=1," # 中等降噪,保留人声清晰度
# "highpass=f=100,lowpass=f=5200," # 切除低频鼓/贝斯 & 高频镲/噪音,聚焦人声区
# "equalizer=f=180:width_type=o:width=2.2:g=-12," # 强衰减低频(背景音乐基础层)
# "equalizer=f=350:width_type=o:width=1.8:g=-9," # 衰减低中频(常见旋律区)
# "equalizer=f=750:width_type=o:width=1.6:g=-7," # 中频衰减,破坏音乐结构
# "equalizer=f=2800:width_type=o:width=1.2:g=-5," # 高中频轻衰减,保持人声自然
# "acompressor=threshold=-26dB:ratio=5.5:attack=7:release=90:makeup=6," # 强压缩,改变动态指纹
# "asetrate=48000*1.05,atempo=1.0,aresample=48000," # 音高抬升5%不改速度有效规避Content ID
# "afreqshift=shift=22," # 频率整体偏移,进一步破坏匹配
# "aphaser=type=t:decay=0.35:delay=3.0:speed=0.5," # 正确参数轻微相位扰动triangular类型防检测
# # "anlmdn=s=0.00012:p=0.0008:r=0.002," # 轻量非线性噪门,清理残余背景音乐
# "volume=1.08," # 补偿音量衰减
# "aresample=async=1:first_pts=0:min_hard_comp=0.100000:first_pts=0", # 强制音视频同步
# # 推流优化FLV适合YouTube RTMP/RTMPS
# "-flvflags", "no_duration_filesize",
# "-f", "flv", youtube_rtmp
# ]
# ffmpeg_command = [
# "ffmpeg","-y","-loglevel","info","-nostdin","-re",
# "-stats","-stats_period","1",
# # 输入稳定
# "-rw_timeout","30000000",
# "-reconnect","1","-reconnect_at_eof","1","-reconnect_streamed","1",
# "-reconnect_delay_max","5",
# # 关键:删除低延迟杀手参数
# "-fflags","+genpts+discardcorrupt",
# "-err_detect","ignore_err",
# "-max_delay","500000",
# "-thread_queue_size","512",
# # 抖音源
# "-headers", douyin_headers,
# "-i", real_url,
# # 视频处理
# "-vf","fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
# # 编码
# "-c:v","libx264",
# "-preset","veryfast",
# "-profile:v","high",
# "-level","4.2",
# "-b:v","4500k","-maxrate","4500k","-bufsize","9000k",
# "-g","50","-keyint_min","50",
# "-r","25",
# "-pix_fmt","yuv420p",
# "-bf","0",
# "-sc_threshold","0",
# "-x264-params","force-cfr=1:scenecut=0:open_gop=0",
# # 音频(强烈建议简化)
# "-c:a","aac","-b:a","128k","-ar","44100","-ac","2",
# "-af","aresample=async=1,volume=1.05",
# # 推流
# "-flvflags","no_duration_filesize",
# "-f","flv", youtube_rtmp
# ]
proc = None
stderr_q = None
reader_t = None
@@ -435,15 +269,17 @@ def start_douyin_youtube_ffplay(
stderr_q = queue.Queue()
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
proc = subprocess.Popen(
ffmpeg_command,
_popen_kw = dict(
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
universal_newlines=True,
)
if _ffmpeg_cwd:
_popen_kw["cwd"] = _ffmpeg_cwd
proc = subprocess.Popen(ffmpeg_command, **_popen_kw)
proc.last_out_ts = time.time()
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))

26
ecosystem.config.cjs Normal file
View 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" },
],
};

View File

@@ -32,13 +32,10 @@ button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
<div class="container">
<h1>
进程:
<select id="processSelect">
<option value="tiktok">tiktok</option>
<option value="youtube">youtube</option>
<option value="obs">obs</option>
</select>
<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>
<!-- YouTube 专属配置(仅 youtube 时显示) -->
<div id="youtubeOnlySection" style="display:none;">
@@ -87,10 +84,57 @@ button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
<pre id="pm2Log">正在加载状态与日志...</pre>
</div>
<script>
/** 进程列表来自 GET /process_monitorweb.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 = 'tiktok';
let currentProcess = '';
// 各专属区域
const youtubeOnlySection = document.getElementById('youtubeOnlySection');
@@ -109,23 +153,32 @@ 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() {
youtubeOnlySection.style.display = currentProcess === 'youtube' ? 'block' : 'none';
urlConfigSection.style.display = (currentProcess === 'youtube' || currentProcess === 'tiktok') ? 'block' : 'none';
obsSection.style.display = currentProcess === 'obs' ? 'block' : 'none';
if (currentProcess === 'youtube') {
loadYoutubeConfig();
}
if (currentProcess === 'youtube' || currentProcess === 'tiktok') {
loadUrlConfig();
}
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 高度自适应
@@ -215,7 +268,9 @@ async function pm2Action(action){
updateStatus(data.process_status);
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()}\n${data.raw_status}\n\n`;
if(data.process_status === 'not_found'){
logText += `【提示】进程 "${currentProcess}" 未注册到 PM2可能被 pm2 delete 删除)\n`;
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('无日志路径')){
@@ -233,7 +288,9 @@ async function pm2Action(action){
logEl.textContent = logText;
scrollLog();
}else{
logEl.textContent = `操作: ${action} ${currentProcess}\n${data.output || '成功'}\n\n正在刷新状态...`;
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);
}
@@ -256,10 +313,26 @@ document.getElementById('pm2Buttons').addEventListener('click', e=>{
if(action && action !== 'status') pm2Action(action);
});
// 自动刷新 + 初始
setInterval(fetchStatus, 1000);
fetchStatus();
updateSectionsVisibility();
(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>

View File

@@ -1,14 +0,0 @@
<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="en"><head><meta content="Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for." name="description"><meta content="noodp, " name="robots"><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"><meta content="/images/branding/googleg/1x/googleg_standard_color_128dp.png" itemprop="image"><title>Google</title><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var _g={kEI:'gbObaeXAG-Gy5NoPmeGiyAg',kEXPI:'0,1304203,2935842,14111,64702,6398,354503,5532962,16,36811390,25228681,123988,28395,11943,53220,39775,61250,14067,23259,37800,28333,79315,7707,7,33385,4719,24374,1,2884,3086,16757,46340,9920,10754,11886,6436,4952,4791,2646,6292,12404,5550,14505,1383,1,2,1515,3355,196,5814,2,4205,1,16100,2,873,2,22,2208,5108,3008,24,771,4074,4,5393,18686,8466,5337,310,535,845,1201,2,11,2,1560,3,5189,1,13,738,1009,2,1746,4,4878,5773,4542,4,2243,240,297,1,1117,389,4,2647,5,2698,2870,298,9253,1275,7,1784,1050,2,1914,5,10456,2,5724,9,602,59,36,1554,4,2570,3341,932,3066,1,2,1402,3,1117,242,2,2356,2576,49,1,8,216,441,158,2,1645,191,4,1928,4,1634,544,4,1568,1032,1,1399,187,259,1846,5,259,5,197,1921,445,19,1421,119,4,2364,2363,5,39,633,1937,4,57,4,3784,4,76,1970,4,265,106,5,3,971,4,966,5,1125,1,2,2,815,3,2932,4,32,4,130,717,1856,4,40,4,2110,4,134,304,323,1,576,1776,1864,42,5,2,479,704,4,14,3,1,449,4,730,5,75,4,1918,1,21,4,465,429,537,1563,1,1090,2,265,702,4905,1788,14,507,823,747,4,331,4,959,452,57,258,615,80,7,2245,4,281,264,341,221,1,2976,508,339,3430,503,3,1412,1354,4,12,33,2093,4,945,16,4,612,1,20998529,1717,7635,5,2992,4,2960,3,8491,2,1562,3,2687,3,9181,536,2,2461,1194,3,746,6,918,231,3,1369,1494,2715,1,6494629,4214,2,3639,565,745,38,517,541,84,108428,25,36,10,3516226,209655,562,143,12493584,256605,48968,4054456,476,441,42,5,116,590,5,1023,1671,844,1,148,3,6,163,101,343,4,65,388,2,707,260,44,4,152,4,488,62,219,3,2,2,2,550,4,885,1050,2,50,641,5,429,6,2800,5,138,1,863,130,45,2,11,2,16,361,218,1148,1,2,642,214,1485,417,712,2695,1043,281,5,330,1255,292,114,289,54,161,5,142,4,587,412,322,525,1,361,503,213,249,99,472,165,7,51,90,174,436,472,3,289,379,458,583,331,601,587,556,100,949,821,118,4,177,37,7,12,3,6,25,1006,3,2,2,2,67,134,727,4,283,321,70,212,125,4,4,3,2,1,2,3,2,2,1,2,2,2,3,1,3,2,1,3,2,1,3,1,2,3,1,2,2,2,3,2,1,3,1,2,3,2,2,2,2,1,3,1,3,2,1,2,10,2,2,3,2,2,10,1,2,3,1,2,10,2,3,2,1,3,9,2,3,1,3,2,1,2,2,2,3,1,2,3,2,2,1,3,1,3,2,1,2,2,2,2,2,3,2,2,1,2,3,2,1,2,2,2,3,1,3,1,3,1,2,2,10,2,3,1,3,1,11,2,2,1,2,2,10,2,3,2,2,1,183,94,60,120,149,39,317,3,2,2,2,744,296,177,5,22,414,377,24,216,1579,8,293,133,136,134,83,118,2,3,2,2,2,956,1,2,785,3,2,2,2,348,295,4,106,229,180,5,62,2,878,573,260,185,936,640,1027,638,713,94,18,3,2,2,2,4,308,593,3,2,2,2,720,519,6,1,4357,427,3,1,370,435,364,71,61,442,901,4',kBL:'eaQ3',kOPI:89978449};(function(){var a;((a=window.google)==null?0:a.stvsc)?google.kEI=_g.kEI:window.google=_g;}).call(this);})();(function(){google.sn='webhp';google.kHL='en';google.rdn=true;})();(function(){
var g=this||self;function k(){return window.google&&window.google.kOPI||null};var l,m=[];function n(a){for(var b;a&&(!a.getAttribute||!(b=a.getAttribute("eid")));)a=a.parentNode;return b||l}function p(a){for(var b=null;a&&(!a.getAttribute||!(b=a.getAttribute("leid")));)a=a.parentNode;return b}function q(a){/^http:/i.test(a)&&window.location.protocol==="https:"&&(google.ml&&google.ml(Error("a"),!1,{src:a,glmm:1}),a="");return a}
function r(a,b,d,c,h){var e="";b.search("&ei=")===-1&&(e="&ei="+n(c),b.search("&lei=")===-1&&(c=p(c))&&(e+="&lei="+c));var f=b.search("&cshid=")===-1&&a!=="slh";c="&zx="+Date.now().toString();g._cshid&&f&&(c+="&cshid="+g._cshid);(d=d())&&(c+="&opi="+d);return"/"+(h||"gen_204")+"?atyp=i&ct="+String(a)+"&cad="+(b+e+c)};l=google.kEI;google.getEI=n;google.getLEI=p;google.ml=function(){return null};google.log=function(a,b,d,c,h,e){e=e===void 0?k:e;d||(d=r(a,b,e,c,h));if(d=q(d)){a=new Image;var f=m.length;m[f]=a;a.onerror=a.onload=a.onabort=function(){delete m[f]};a.src=d}};google.logUrl=function(a,b){b=b===void 0?k:b;return r("",a,b)};}).call(this);(function(){google.y={};google.sy={};function e(a,b,d){if(a)var c=a.id;else{do c=Math.random();while(d[c])}d[c]=[a,b]}var f;(f=google).x||(f.x=function(a,b){e(a,b,google.y)});var g;(g=google).sx||(g.sx=function(a,b){e(a,b,google.sy)});google.bx=!1;var h;(h=google).lx||(h.lx=function(){});var k=[],l;(l=google).fce||(l.fce=function(a,b,d,c){k.push([a,b,d,c])});google.qce=k;google.adl=[];}).call(this);google.f={};(function(){
document.documentElement.addEventListener("submit",function(b){var a;if(a=b.target){var c=a.getAttribute("data-submitfalse");a=c==="1"||c==="q"&&!a.elements.q.value?!0:!1}else a=!1;a&&(b.preventDefault(),b.stopPropagation())},!0);document.documentElement.addEventListener("click",function(b){var a;a:{for(a=b.target;a&&a!==document.documentElement;a=a.parentElement)if(a.tagName==="A"){a=a.getAttribute("data-nohref")==="1";break a}a=!1}a&&b.preventDefault()},!0);}).call(this);</script><style>#gbar,#guser{font-size:13px;padding-top:1px !important;}#gbar{height:22px}#guser{padding-bottom:7px !important;text-align:right}.gbh,.gbd{border-top:1px solid #c9d7f1;font-size:1px}.gbh{height:0;position:absolute;top:24px;width:100%}@media all{.gb1{height:22px;margin-right:.5em;vertical-align:top}#gbar{float:left}}a.gb1,a.gb4{text-decoration:underline !important}a.gb1,a.gb4{color:#00c !important}.gbi .gb4{color:#dd8e27 !important}.gbf .gb4{color:#900 !important}
</style><style>body,td,a,p,.h{font-family:sans-serif}body{margin:0;overflow-y:scroll}#gog{padding:3px 8px 0}td{line-height:.8em}.gac_m td{line-height:17px}form{margin-bottom:20px}.h{color:#1967d2}em{font-weight:bold;font-style:normal}.lst{height:25px;width:496px}.gsfi,.lst{font:18px sans-serif}.gsfs{font:17px sans-serif}.ds{display:inline-box;display:inline-block;margin:3px 0 4px;margin-left:4px}input{font-family:inherit}body{background:#fff;color:#1f1f1f}a{color:#681da8;text-decoration:none}a:hover,a:active{text-decoration:underline}.fl a{color:#1967d2}a:visited{color:#681da8}.sblc{padding-top:5px}.sblc a{display:block;margin:2px 0;margin-left:13px;font-size:11px}.lsbb{background:#ecedee;border:solid 1px;border-color:#d2d2d2 #70757a #70757a #d2d2d2;height:30px}.lsbb{display:block}#WqQANb a{display:inline-block;margin:0 12px}.lsb{background:url(/images/nav_logo229.png) 0 -261px repeat-x;color:#1f1f1f;border:none;cursor:pointer;height:30px;margin:0;outline:0;font:15px sans-serif;vertical-align:top}.lsb:active{background:#dadce0}.lst:focus{outline:none}</style><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.erd={jsr:1,bv:2383,de:true,dpf:'UAON6G49novXd1LJI--iGd6VfzOOuK54njDRAqwNAgA'};
var g=this||self;var k,l=(k=g.mei)!=null?k:1,m,p=(m=g.diel)!=null?m:0,q,r=(q=g.sdo)!=null?q:!0,t=0,u,w=google.erd,x=w.jsr;google.ml=function(a,b,d,n,e){e=e===void 0?2:e;b&&(u=a&&a.message);d===void 0&&(d={});d.cad="ple_"+google.ple+".aple_"+google.aple;if(google.dl)return google.dl(a,e,d,!0),null;b=d;if(x<0){window.console&&console.error(a,b);if(x===-2)throw a;b=!1}else b=!a||!a.message||a.message==="Error loading script"||t>=l&&!n?!1:!0;if(!b)return null;t++;d=d||{};b=encodeURIComponent;var c="/gen_204?atyp=i&ei="+b(google.kEI);google.kEXPI&&(c+="&jexpid="+b(google.kEXPI));c+="&srcpg="+b(google.sn)+"&jsr="+b(w.jsr)+
"&bver="+b(w.bv);w.dpf&&(c+="&dpf="+b(w.dpf));var f=a.lineNumber;f!==void 0&&(c+="&line="+f);var h=a.fileName;h&&(h.indexOf("-extension:/")>0&&(e=3),c+="&script="+b(h),f&&h===window.location.href&&(f=document.documentElement.outerHTML.split("\n")[f],c+="&cad="+b(f?f.substring(0,300):"No script found.")));google.ple&&google.ple===1&&(e=2);c+="&jsel="+e;for(var v in d)c+="&",c+=b(v),c+="=",c+=b(d[v]);c=c+"&emsg="+b(a.name+": "+a.message);c=c+"&jsst="+b(a.stack||"N/A");c.length>=12288&&(c=c.substring(0,12288));a=c;n||google.log(0,"",a);return a};window.onerror=function(a,b,d,n,e){u!==a&&(a=e instanceof Error?e:Error(a),d===void 0||"lineNumber"in a||(a.lineNumber=d),b===void 0||"fileName"in a||(a.fileName=b),google.ml(a,!1,void 0,!1,a.name==="SyntaxError"||a.message.substring(0,11)==="SyntaxError"||a.message.indexOf("Script error")!==-1?3:p));u=null;r&&t>=l&&(window.onerror=null)};})();</script></head><body bgcolor="#fff"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var src='/images/nav_logo229.png';var iesg=false;document.body.onload = function(){window.n && window.n();if (document.images){new Image().src=src;}
if (!iesg){document.f&&document.f.q.focus();document.gbqf&&document.gbqf.q.focus();}
}
})();</script><div id="mngb"><div id=gbar><nobr><b class=gb1>Search</b> <a class=gb1 href="https://www.google.com/imghp?hl=en&tab=wi">Images</a> <a class=gb1 href="http://maps.google.com/maps?hl=en&tab=wl">Maps</a> <a class=gb1 href="https://play.google.com/?hl=en&tab=w8">Play</a> <a class=gb1 href="https://www.youtube.com/?tab=w1">YouTube</a> <a class=gb1 href="https://news.google.com/?tab=wn">News</a> <a class=gb1 href="https://mail.google.com/mail/?tab=wm">Gmail</a> <a class=gb1 href="https://drive.google.com/?tab=wo">Drive</a> <a class=gb1 style="text-decoration:none" href="https://www.google.com/intl/en/about/products?tab=wh"><u>More</u> &raquo;</a></nobr></div><div id=guser width=100%><nobr><span id=gbn class=gbi></span><span id=gbf class=gbf></span><span id=gbe></span><a href="http://www.google.com/history/optout?hl=en" class=gb4>Web History</a> | <a href="/preferences?hl=en" class=gb4>Settings</a> | <a target=_top id=gb_70 href="https://accounts.google.com/ServiceLogin?hl=en&passive=true&continue=http://www.google.com/&ec=GAZAAQ" class=gb4>Sign in</a></nobr></div><div class=gbh style=left:0></div><div class=gbh style=right:0></div></div><center><br clear="all" id="lgpd"><div><img alt="Google" height="92" src="/images/branding/googlelogo/1x/googlelogo_white_background_color_272x92dp.png" style="padding:28px 0 14px" width="272" id="hplogo"><br><br></div><form action="/search" name="f"><table cellpadding="0" cellspacing="0"><tr valign="top"><td width="25%">&nbsp;</td><td align="center" nowrap=""><input name="ie" value="ISO-8859-1" type="hidden"><input value="en" name="hl" type="hidden"><input name="source" type="hidden" value="hp"><input name="biw" type="hidden"><input name="bih" type="hidden"><div class="ds" style="height:32px;margin:4px 0"><input class="lst" style="margin:0;padding:5px 8px 0 6px;vertical-align:top;color:#1f1f1f" autocomplete="off" value="" title="Google Search" maxlength="2048" name="q" size="57"></div><br style="line-height:0"><span class="ds"><span class="lsbb"><input class="lsb" value="Google Search" name="btnG" type="submit"></span></span><span class="ds"><span class="lsbb"><input class="lsb" id="tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1" value="I'm Feeling Lucky" name="btnI" type="submit"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var id='tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1';document.getElementById(id).onclick = function(){if (this.form.q.value){this.checked = 1;if (this.form.iflsig)this.form.iflsig.disabled = false;}
else top.location='/doodles/';};})();</script><input value="AFdpzrgAAAAAaZvBkZ6x6tP9yqY4FfFqM-NYRs9cR3zt" name="iflsig" type="hidden"></span></span></td><td class="fl sblc" align="left" nowrap="" width="25%"><a href="/advanced_search?hl=en&amp;authuser=0">Advanced search</a></td></tr></table><input id="gbv" name="gbv" type="hidden" value="1"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var a,b="1";if(document&&document.getElementById)if(typeof XMLHttpRequest!="undefined")b="2";else if(typeof ActiveXObject!="undefined"){var c,d,e=["MSXML2.XMLHTTP.6.0","MSXML2.XMLHTTP.3.0","MSXML2.XMLHTTP","Microsoft.XMLHTTP"];for(c=0;d=e[c++];)try{new ActiveXObject(d),b="2"}catch(h){}}a=b;if(a=="2"&&location.search.indexOf("&gbv=2")==-1){var f=google.gbvu,g=document.getElementById("gbv");g&&(g.value=a);f&&window.setTimeout(function(){location.href=f},0)};}).call(this);</script></form><div style="font-size:83%;min-height:3.5em"><br></div><span id="footer"><div style="font-size:10pt"><div style="margin:19px auto;text-align:center" id="WqQANb"><a href="/intl/en/ads/">Advertising</a><a href="/services/">Business Solutions</a><a href="/intl/en/about.html">About Google</a></div></div><p style="font-size:8pt;color:#636363">&copy; 2026 - <a href="/intl/en/policies/privacy/">Privacy</a> - <a href="/intl/en/policies/terms/">Terms</a></p></span></center><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.cdo={height:757,width:1440};(function(){var a=window.innerWidth,b=window.innerHeight;if(!a||!b){var c=window.document,d=c.compatMode=="CSS1Compat"?c.documentElement:c.body;a=d.clientWidth;b=d.clientHeight}if(a&&b&&(a!=google.cdo.width||b!=google.cdo.height)){var e=google,f=e.log,g="/client_204?&atyp=i&biw="+a+"&bih="+b+"&ei="+google.kEI,h="",k=window.google&&window.google.kOPI||null;k&&(h+="&opi="+k);f.call(e,"","",g+h)};}).call(this);})();(function(){google.xjs={basecomb:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/ck\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAICAQAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAIAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d0/ujg\x3d1/rs\x3dACT90oHYvixg5Dv0yQj-SB1JU6LIcj3yYQ',basecss:'/xjs/_/ss/k\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAIAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAI/rs\x3dACT90oFMtqhjIKGT4rLyv9oL3Z6ncHStwQ',basejs:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/dg\x3d0/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA',excm:[]};})();(function(){var u='/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d4/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA/m\x3dsb_he,d';var st=1;var amd=1000;var mmd=0;var pop=true;var povp=false;var raf=false;var lfpp=false;var ecb=false;var cst=false;var pxp=false;
var f=this||self;function g(){var a=google.ia&&google.ia.r.B2Jtyd;return a&&[1,5,6].indexOf(a.m)>=0&&a.cbfd&&a.cbvi?a:void 0};function h(a){var b=document.createElement("link");b.as="script";b.href=a;b.rel="preload";document.body.appendChild(b)}function k(){var a=[l];google.dp||(a.forEach(h),google.dp=!0)};google.ps=google.ps||[];function m(a){var b=l,c=function(){};google.lx=google.stvsc?c:function(){n(b,a&&raf,a&&lfpp);google.lx=c};google.bx||google.lx()}function p(a,b){b&&(a.src=b);var c=a.onload;a.onload=function(d){c&&c(d);google.ps=google.ps.filter(function(e){return a.src!==e})};google.ps.push(a.src);document.body.appendChild(a)}function q(a,b,c,d){c&&(a.fetchPriority="low");b?requestAnimationFrame(function(){p(a,d)}):p(a,d)}google.as=p;function n(a,b,c){google.tick&&google.tick("load","xjsls");var d=document.createElement("script");d.onerror=function(){google.ple=1};d.onload=function(){google.ple=0};google.xjsus=void 0;q(d,b,c,a);google.aple=-1;google.dp=!0};function r(){for(var a=document.getElementsByTagName("img"),b=0,c=a.length;b<c;b++){var d=a[b],e;if(e=d.hasAttribute("data-lzy_")&&Number(d.getAttribute("data-atf"))&1)e=d.getAttribute("jscontroller"),e=!((e==="UBXHI"||e==="R3fhkb"||e==="TSZEqd")&&d.hasAttribute("data-src"));if(e)return!0}return!1};var l,t,w,x,y,z,A,B,C,D,E="";function F(){l=pxp&&google.xjsup||u;var a=l.match("/cb=(loaded_h_\\d+)");a&&a[1]&&(E=a[1]);google.xjsu=l;f._F_jsUrl=l;z=function(c){m(c)};t=!1;w=(st===1||st===3)&&!!google.caft&&!r();x=g();y=!E&&(st===2||st===3)&&!!x&&!r();if(E){var b=(st===2||st===3)&&!!x;f[E]=function(c){var d=!1,e=function(){d||(d=!0,cst?setTimeout(function(){return void c.call(window,window._)},0):c.call(window,window._))};b&&google.ia.adls?(x.cbvi.push(function(){delete google.ia.adls}),x.cbvi.push(e),setTimeout(e,mmd)):e()}}A=
pop;B=povp;C=A&&document.prerendering||B&&document.hidden;D=B?"visibilitychange":"prerenderingchange"}function G(a){t||w||y||C||(z(a),t=!0)}
setTimeout(function(){google.tick&&google.tick("load","xjspls");F();if(w||y||C){if(w){var a=function(){w=!1;G()};google.caft(a);setTimeout(a,amd)}y&&(a=function(){y=!1;G()},x.cbvi.push(a),setTimeout(a,mmd));if(C){var b=function(){(B?document.hidden:document.prerendering)||(C=!1,G(!B),document.removeEventListener(D,b))};document.addEventListener(D,b,{passive:!0})}t||k()}else z()},0);})();window._ = window._ || {};window._DumpException = _._DumpException = function(e){throw e;};window._s = window._s || {};_s._DumpException = _._DumpException;window._qs = window._qs || {};_qs._DumpException = _._DumpException;window.loaded_h_0 = function(cb){cb.call(window,window._);};(function(){var t=[512,0,2048,0,0,0,4194304,33554432,268437004,0,8192,0,4096,4259020,137036800,134234113,548405252,4196417,8192,134217728,1048576,8,1170496,0,72908928,0,568328192];window._F_toggles = window._xjs_toggles = t;})();window._F_installCss = window._F_installCss || function(css){};(function(){var pmc='{\x22d\x22:{},\x22sb_he\x22:{\x22client\x22:\x22heirloom-hp\x22,\x22dh\x22:true,\x22ds\x22:\x22\x22,\x22host\x22:\x22google.com\x22,\x22jsonp\x22:true,\x22msgs\x22:{\x22cibl\x22:\x22Clear Search\x22,\x22dym\x22:\x22Did you mean:\x22,\x22lcky\x22:\x22I\\u0026#39;m Feeling Lucky\x22,\x22lml\x22:\x22Learn more\x22,\x22psrc\x22:\x22This search was removed from your \\u003Ca href\x3d\\\x22/history\\\x22\\u003EWeb History\\u003C/a\\u003E\x22,\x22psrl\x22:\x22Remove\x22,\x22sbit\x22:\x22Search by image\x22,\x22srch\x22:\x22Google Search\x22},\x22ovr\x22:{},\x22pq\x22:\x22\x22,\x22rfs\x22:[],\x22stok\x22:\x22VgWHNe9WWnZFTDWuPRUlX6IPsTw\x22}}';google.pmc=JSON.parse(pmc);})();</script></body></html>

View File

@@ -1,215 +0,0 @@
<!--
Project: DouyinLiveRecorder
Author: Hmily
Build: 2023.08.14 - 20:24:05
GitHub Project URL: https://github.com/ihmily/DouyinLiveRecorder
-->
<!DOCTYPE html>
<html lang="en">
<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&amp;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>
</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>&copy; 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;
}
videoPlayer.style.display = 'block';
description.style.display = 'none';
} else {
console.error('不支持播放该视频格式。');
alert('不支持播放该视频格式。');
}
}
document.getElementById('playButton').addEventListener('click', playVideo);
</script>
</div>
</body>
</html>

16
mobile/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

View 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
}
}

Binary file not shown.

14
mobile/android/app/proguard-rules.pro vendored Normal file
View 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:

View 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>

View 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>

View 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>

View File

@@ -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()
}
}

View File

@@ -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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Some files were not shown because too many files have changed in this diff Show More