diff --git a/README.md b/README.md index 1a6181b..b67dcb1 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Windows 桌面端 **抖音 → YouTube 无人转播** 控制台:内置 Electro - [推流诊断 / 质量守卫 / 转播历史](#推流诊断--质量守卫--转播历史) - [youLIVE 目标端监控](#youlive-目标端监控) - [摄像头直推](#摄像头直推) +- [外部拉流转推](#外部拉流转推) - [会员登录门禁(可选)](#会员登录门禁可选) - [开发与构建](#开发与构建) - [打包发布](#打包发布) @@ -84,7 +85,7 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload ``` ┌─────────────────────────────────────────────────────────────────┐ │ Electron 渲染进程 (React + Vite) │ -│ 直播 / 摄像头 / 网络 / 系统 │ +│ 直播 / 摄像头 / 拉流 / 网络 / 系统 │ └───────────────────────────┬─────────────────────────────────────┘ │ preload (contextBridge) ┌───────────────────────────▼─────────────────────────────────────┐ @@ -103,8 +104,9 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload │ ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ - youtube2.py douyin_youtube_ffplay.py tiktok*.py - │ │ + youtube2.py douyin_youtube_ffplay.py camera_youtube.py + │ │ │ + │ pull_stream_youtube.py │ └──────── FFmpeg + Supervisor / PM2 ────────────────┘ ``` @@ -118,6 +120,7 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload | 配置治理 | `web2_config_governance.py` | 保存前备份、恢复 | | 多频道 | `web2_relay_pro.py` | `youtube_channels.json`、`config/relay_pro/` | | 摄像头 | `web2_camera.py`, `camera_youtube.py` | DirectShow 采集、NVENC 推流 | +| 拉流 | `web2_pull_stream.py`, `pull_stream_youtube.py` | RTMP/SRT/HLS/FLV/WHEP 拉流转推 | | youLIVE | `electron/src/youlive/` | YouTube 监听、弹幕、账号、桌面通知 | | 主进程 | `electron/src/main/` | 运行时打包、IPC、本地 DB | | 界面 | `electron/src/renderer/` | React SPA | @@ -131,7 +134,7 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload ├── electron/ # 无人直播助手(Electron + React) │ ├── src/main/ # 主进程 │ ├── src/preload/ # contextBridge -│ ├── src/renderer/ # UI(直播 / 摄像头 / 网络 / 系统) +│ ├── src/renderer/ # UI(直播 / 摄像头 / 拉流 / 网络 / 系统) │ ├── src/youlive/ # 内嵌 youLIVE(主进程服务 + 共享类型) │ └── scripts/ # 离线运行时、图标 ├── web2.py # FastAPI 入口 @@ -141,6 +144,7 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload ├── runtime/youtube_relay/ # 转播会话 sessions.sqlite ├── src/ # 录制核心(spider、stream、room…) ├── camera_youtube.py # 摄像头→YouTube worker +├── pull_stream_youtube.py # 外部拉流→YouTube worker ├── douyin_youtube_ffplay.py # 抖音→YouTube 转播 ├── youtube2.py # YouTube worker ├── docs/ # 补充文档 @@ -153,11 +157,16 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload | 页面 | 说明 | |------|------| -| **直播** | 单频道 / Pro:抖音源站转播、密钥、监听、启停、链路诊断、转播历史 | -| **摄像头** | 物理摄像头 + 麦克风 → FFmpeg → YouTube(`camera_youtube` 进程) | +| **直播** | 抖音源站无人转播:单频道 / Pro、链路诊断、转播历史、youLIVE 目标端监控 | +| **摄像头** | 本机采集直推:设备预览、采集/编码配置、独立控制台(进程 `camera_youtube`) | +| **拉流** | 外部流转推:RTMP/SRT/HLS/FLV/WHEP 拉流、转码或流复制(进程 `pull_stream_youtube`) | | **网络** | 测速、代理 | | **系统** | 负载、开机启动、路径、会员登录限制开关 | +**直播** 页面向抖音无人转播业务,含 Pro 多频道、三层链路诊断、转播历史等。 + +**摄像头** / **拉流** 页各自独立 UI 与工作流,不复用直播页布局;侧栏深链 `--nav=camera` / `--nav=pull` 可直达。 + 直播页能力摘要: - **自适应轮询**:推流中 ~0.8s,空闲 ~4s,后台标签 ~30s @@ -191,6 +200,8 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload | 诊断 | `GET /youtube/quality_guard` | 质量评分(诊断内部复用) | | 摄像头 | `GET /camera/devices` | FFmpeg 枚举音视频采集设备 | | 摄像头 | `GET/POST /camera/config` | 读写 `camera_live.ini` | +| 拉流 | `GET/POST /pull_stream/config` | 读写 `pull_stream.ini` | +| 拉流 | `GET /pull_stream/protocols` | 协议预设与示例地址 | | 历史 | `GET /youtube/relay/sessions` | 转播会话 | | 历史 | `POST /youtube/relay/sessions/import` | 从 PM2 日志导入 | | 网络 | `GET /network_status` `GET/POST /proxy_config` | 测速与代理 | @@ -221,6 +232,7 @@ Electron 主进程自动注入 API 鉴权头。 | `config/google_oauth.example.json` | YouTube OAuth 模板 | | `config/ffmpeg_proxy.ini` | FFmpeg 代理 | | `config/camera_live.ini` | 摄像头采集参数(见 `camera_live.example.ini`) | +| `config/pull_stream.ini` | 外部拉流参数(见 `pull_stream.example.ini`) | | `backup_config/__web__/` | 配置版本备份 | | `runtime/youtube_relay/sessions.sqlite` | 转播会话库 | | 用户数据目录 `client-data.sqlite` | Electron 本地事件与快照 | @@ -298,21 +310,55 @@ Electron 主进程自动注入 API 鉴权头。 | **桌面通知** | 推流未到达 YouTube、目标端下播、弹幕关键词命中时通知(需在 youLIVE 设置中开启) | | **本地存储** | `youlive` SQLite 保存监听项、会话、告警历史 | -直播页 **YouTube 目标端监控** 面板在 Pro / 单路模式下自动同步监听地址;摄像头页亦可绑定 `youtube_watch_url` 做目标端校验。 +直播页 **YouTube 目标端监控** 面板在 Pro / 单路模式下自动同步监听地址。 --- ## 摄像头直推 -**摄像头** 页将本机物理摄像头 + 麦克风经 FFmpeg 直推 YouTube RTMPS,进程名 `camera_youtube`(与抖音转播 `youtube2` 独立)。 +**摄像头** 页面向本机采集业务,布局为「预览 + 控制台」:左侧实时画面预览,右侧采集→编码→YouTube 链路状态与启停。 -1. 在界面选择 **视频设备** / **音频设备**(后端 `GET /camera/devices` 枚举 DirectShow 设备)。 -2. 设置分辨率、帧率、码率;可选 NVENC 硬件编码。 +进程名 `camera_youtube`,与抖音转播 `youtube2` 独立,请勿同时开启冲突 worker。 + +1. 选择 **视频设备** / **音频设备**(`GET /camera/devices` 枚举 DirectShow)。 +2. 设置分辨率、帧率、码率;可选 NVENC。 3. 填写 YouTube **串流密钥**(复用 `config/youtube.ini`)。 -4. 可选填写 **YouTube 直播页地址** 供目标端监听。 -5. 点击 **开始直播**。 +4. 点击 **开始直播**。 -配置持久化于 `config/camera_live.ini`(模板见 `config/camera_live.example.ini`)。 +配置:`config/camera_live.ini`(模板 `config/camera_live.example.ini`)。 + +--- + +## 外部拉流转推 + +**拉流** 页面向已有流媒体管线:从 SRS、OBS、其他节点提供的地址拉流,转码或复制后推到 YouTube Live。 + +进程名 `pull_stream_youtube`。 + +### 支持协议 + +| 协议 | 示例 | +|------|------| +| RTMP / RTMPS | `rtmp://127.0.0.1/live/stream` | +| SRT | `srt://host:10080?mode=caller&streamid=…` | +| HLS | `https://example.com/live/index.m3u8` | +| HTTP-FLV | `http://127.0.0.1:8080/live/stream.flv` | +| WebRTC (WHEP) | `whep://…`(需 FFmpeg 支持;否则经 SRS 转 RTMP/SRT) | +| UDP / MPEG-TS | `udp://@:1234` | + +### 处理模式 + +- **转码**(默认):H.264 + AAC,兼容 YouTube +- **流复制**:源须已是 H.264 + AAC,延迟更低 + +### 使用步骤 + +1. 打开 **拉流** 页,填写拉流 URL(可点协议预设快速填入示例)。 +2. 选择协议(可自动识别)、转码/复制、码率与 NVENC。 +3. 填写 YouTube 串流密钥 → **开始转推**。 +4. SRT 延迟、断线重连、额外 FFmpeg 参数等在「高级参数」中配置。 + +配置:`config/pull_stream.ini`(模板 `config/pull_stream.example.ini`)。 --- @@ -380,7 +426,9 @@ pnpm dist | `relay_slow` / 掉帧 | 查 NVENC;降分辨率;关占用 CPU 的程序 | | Pro 无法启动 | 勿 `WEB2_SUPERVISOR=pm2`;每频道密钥与 URL 已保存;检查配对冲突横幅 | | 摄像头无画面 | 确认 DirectShow 设备名与 `camera_live.ini` 一致;尝试关闭 GPU 编码 | -| 目标端监听无数据 | 确认 `youtubeWatchUrl` / `youtube_watch_url` 已填写;youLIVE 桌面通知已开启 | +| 拉流连接失败 | 核对协议与 URL;SRT 检查 caller/streamid;WebRTC 建议经 SRS 转 RTMP | +| 拉流有画面 YouTube 无 | 流复制模式下源可能非 H.264+AAC,改转码模式 | +| 目标端监听无数据 | 直播页确认 `youtubeWatchUrl` 已填写;youLIVE 桌面通知已开启 | | 配置误改 | 直播页 **历史备份** 或 `POST /config/restore` | 日志: diff --git a/config/pull_stream.example.ini b/config/pull_stream.example.ini new file mode 100644 index 0000000..73f9d63 --- /dev/null +++ b/config/pull_stream.example.ini @@ -0,0 +1,16 @@ +[pull_stream] +# 拉流地址:支持 RTMP / RTMPS / SRT / HLS(m3u8) / HTTP-FLV / WHEP / UDP +source_url = +# auto | rtmp | rtmps | srt | hls | http-flv | webrtc | udp +protocol = auto +# transcode=转码推 YouTube;copy=直接复制(源须为 H.264+AAC) +transcode = transcode +bitrate = 4500 +use_gpu = 是 +srt_latency_ms = 200 +reconnect = 是 +# YouTube 频道/直播页,供目标端监听 +youtube_watch_url = +label = +# 额外 FFmpeg 输入参数(可选,空格分隔) +extra_input_args = diff --git a/electron/src/main/appHost.ts b/electron/src/main/appHost.ts index b178e8c..479983b 100644 --- a/electron/src/main/appHost.ts +++ b/electron/src/main/appHost.ts @@ -6,7 +6,7 @@ import { app, BrowserWindow, ipcMain, shell } from 'electron' import fs from 'fs' import path from 'path' -const VALID_NAV = new Set(['record', 'camera', 'network', 'system']) +const VALID_NAV = new Set(['record', 'camera', 'pull', 'network', 'system']) export function parseNavFromArgv(argv: string[]): string | null { for (const a of argv) { diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index f9a4818..234991e 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -3953,6 +3953,591 @@ button.net-badge { max-width: 920px; } +/* ── 独立功能页(摄像头 / 拉流)──────────────────────── */ +.feature-page { + max-width: 1080px; + margin: 0 auto; + padding: 0 0 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.feature-page__intro { + margin: 0; +} + +.feature-page__title { + margin: 0 0 0.35rem; + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.feature-page__lead { + margin: 0; + font-size: 0.88rem; + line-height: 1.55; + color: var(--text-muted); + max-width: 62ch; +} + +.feature-hero { + display: grid; + gap: 1rem; + align-items: stretch; +} + +.feature-hero--camera { + grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr); +} + +.feature-hero--pull { + grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr); +} + +@media (max-width: 900px) { + .feature-hero--camera, + .feature-hero--pull { + grid-template-columns: 1fr; + } +} + +.feature-hero__visual { + border-radius: 14px; + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--surface-1, rgba(0, 0, 0, 0.2)); + padding: 0.85rem; + display: flex; + flex-direction: column; + gap: 0.65rem; + min-height: 280px; +} + +.camera-preview { + position: relative; + flex: 1; + min-height: 200px; + border-radius: 10px; + overflow: hidden; + background: #0a0a0c; +} + +.camera-preview__video { + width: 100%; + height: 100%; + min-height: 200px; + object-fit: cover; + display: block; + opacity: 0; +} + +.camera-preview__video.is-live { + opacity: 1; +} + +.camera-preview__placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + text-align: center; + font-size: 0.82rem; + color: var(--text-muted); + background: rgba(0, 0, 0, 0.35); +} + +.camera-preview__placeholder--err { + color: #f56565; +} + +.camera-preview__actions { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + display: flex; + gap: 0.35rem; + z-index: 2; +} + +.feature-hero__visual .camera-preview { + flex: 1; +} + +.feature-hero__visual-head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.78rem; + color: var(--text-muted); +} + +.feature-hero__device-name { + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +.feature-hero__aside { + border-radius: 14px; + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: linear-gradient(165deg, rgba(124, 156, 255, 0.06), rgba(0, 0, 0, 0.15)); + padding: 1rem 1.1rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.feature-hero__status-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.feature-status-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.28rem 0.65rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + border: 1px solid transparent; +} + +.feature-status-badge.is-live { + color: #68d391; + background: rgba(104, 211, 145, 0.12); + border-color: rgba(104, 211, 145, 0.35); +} + +.feature-status-badge.is-ready { + color: #7c9cff; + background: rgba(124, 156, 255, 0.12); + border-color: rgba(124, 156, 255, 0.3); +} + +.feature-status-badge.is-warn { + color: #ed8936; + background: rgba(237, 137, 54, 0.1); + border-color: rgba(237, 137, 54, 0.3); +} + +.feature-status-badge--muted { + color: var(--text-muted); + background: rgba(255, 255, 255, 0.04); + border-color: var(--border-subtle, rgba(255, 255, 255, 0.08)); + font-weight: 500; +} + +.worker-flow { + display: flex; + align-items: stretch; + gap: 0; +} + +.worker-flow__item-wrap { + display: flex; + align-items: center; + flex: 1; + min-width: 0; +} + +.worker-flow__arrow { + width: 14px; + height: 2px; + margin: 0 2px; + background: rgba(255, 255, 255, 0.15); + flex-shrink: 0; +} + +.worker-flow__arrow.is-lit { + background: linear-gradient(90deg, rgba(124, 156, 255, 0.2), rgba(124, 156, 255, 0.85)); +} + +.worker-flow__step { + flex: 1; + min-width: 0; + padding: 0.45rem 0.5rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.15); +} + +.worker-flow__step--active { + border-color: rgba(104, 211, 145, 0.45); + background: rgba(104, 211, 145, 0.08); +} + +.worker-flow__step--pending { + border-color: rgba(124, 156, 255, 0.35); + background: rgba(124, 156, 255, 0.06); +} + +.worker-flow__step--warn { + border-color: rgba(237, 137, 54, 0.45); + background: rgba(237, 137, 54, 0.08); +} + +.worker-flow__step--error { + border-color: rgba(245, 101, 101, 0.5); + background: rgba(245, 101, 101, 0.1); +} + +.worker-flow__kicker { + display: block; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.worker-flow__title { + display: block; + font-size: 0.78rem; + font-weight: 600; + margin-top: 0.1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.worker-checklist { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.worker-checklist li { + display: flex; + align-items: baseline; + gap: 0.45rem; + font-size: 0.8rem; +} + +.worker-checklist__mark { + width: 1rem; + text-align: center; + flex-shrink: 0; +} + +.worker-checklist li.is-ok .worker-checklist__mark { + color: #68d391; +} + +.worker-checklist li.is-pending .worker-checklist__mark { + color: var(--text-muted); +} + +.worker-checklist__hint { + font-size: 0.72rem; + color: var(--text-muted); +} + +.worker-metrics { + padding: 0.65rem 0.75rem; + border-radius: 10px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.worker-metrics__row { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.55rem; +} + +.worker-metrics__pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + font-weight: 600; +} + +.worker-metrics__pill--muted { + font-weight: 500; + color: var(--text-muted); +} + +.worker-metrics__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem 0.75rem; + margin: 0; + font-size: 0.76rem; +} + +.worker-metrics__grid dt { + color: var(--text-muted); + margin: 0; +} + +.worker-metrics__grid dd { + margin: 0.1rem 0 0; + font-weight: 600; +} + +.worker-metrics__grid dd.is-warn { + color: #ed8936; +} + +.worker-metrics__grid .mono { + font-family: ui-monospace, monospace; + font-size: 0.72rem; + word-break: break-all; +} + +.feature-action-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: auto; +} + +.btn--lg { + padding: 0.55rem 1.25rem; + font-size: 0.92rem; +} + +.feature-hint, +.feature-notice { + margin: 0; + font-size: 0.78rem; + color: var(--text-muted); +} + +.feature-notice { + color: #7c9cff; +} + +.feature-error { + margin: 0 0 0.5rem; + font-size: 0.82rem; + color: #f56565; +} + +.feature-split { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.feature-split--3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 960px) { + .feature-split--3 { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .feature-split { + grid-template-columns: 1fr; + } +} + +.feature-card { + border-radius: 12px; + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--surface-1, rgba(255, 255, 255, 0.02)); + padding: 1rem 1.05rem; +} + +.feature-card__title { + margin: 0 0 0.35rem; + font-size: 0.95rem; + font-weight: 650; +} + +.feature-card__desc, +.feature-card__note { + margin: 0 0 0.75rem; + font-size: 0.8rem; + color: var(--text-muted); + line-height: 1.5; +} + +.feature-card__note--span2 { + grid-column: 1 / -1; +} + +.feature-card__collapse-head { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0; + border: none; + background: none; + color: inherit; + cursor: pointer; + text-align: left; +} + +.feature-card__collapse-head .feature-card__title { + margin: 0; +} + +.feature-card__collapse-head span { + font-size: 0.78rem; + color: var(--text-muted); +} + +.feature-card__collapse-body { + margin-top: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.pull-source-panel { + border-radius: 14px; + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--surface-1, rgba(0, 0, 0, 0.18)); + padding: 1rem 1.1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 280px; +} + +.pull-source-panel__field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.pull-source-panel__field span { + font-size: 0.78rem; + color: var(--text-muted); +} + +.pull-source-panel__input { + font-size: 0.92rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.pull-source-panel__meta { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.pull-protocol-badge { + display: inline-block; + padding: 0.22rem 0.55rem; + border-radius: 6px; + font-size: 0.74rem; + font-weight: 600; + border: 1px solid rgba(124, 156, 255, 0.35); + background: rgba(124, 156, 255, 0.1); + color: #a8c0ff; +} + +.pull-protocol-badge.is-warn { + border-color: rgba(237, 137, 54, 0.4); + background: rgba(237, 137, 54, 0.1); + color: #ed8936; +} + +.pull-protocol-badge--muted { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: var(--text-muted); + font-weight: 500; +} + +.feature-log { + border-radius: 10px; + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: rgba(0, 0, 0, 0.15); + padding: 0.65rem 0.85rem; +} + +.feature-log summary { + cursor: pointer; + font-size: 0.84rem; + font-weight: 600; + color: var(--text-muted); + user-select: none; +} + +.feature-log__pre { + margin: 0.65rem 0 0; + padding: 0.65rem; + border-radius: 8px; + background: rgba(0, 0, 0, 0.25); + font-size: 0.72rem; + line-height: 1.45; + max-height: 220px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.feature-log__pre--err { + color: #f56565; + margin-top: 0.45rem; +} + +.app-main--pull { + max-width: none; +} + +.pull-preset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.45rem; + margin-top: auto; +} + +.pull-preset-chip { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + padding: 0.5rem 0.6rem; + border-radius: 8px; + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--surface-2, rgba(255, 255, 255, 0.03)); + color: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.pull-preset-chip.is-active { + border-color: rgba(124, 156, 255, 0.55); + background: rgba(124, 156, 255, 0.12); +} + +.pull-preset-chip:hover { + border-color: var(--accent, #7c9cff); + background: rgba(124, 156, 255, 0.08); +} + +.pull-preset-chip strong { + font-size: 0.78rem; +} + +.pull-preset-chip span { + font-size: 0.68rem; + color: var(--text-muted); + word-break: break-all; + line-height: 1.35; +} + .live-yt-monitor__warn { margin: 0.5rem 0 0; padding: 0.45rem 0.65rem; diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 6596170..211707f 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -12,6 +12,7 @@ import { type PbUserSnapshot, } from './api' import CameraLivePage from './CameraLivePage' +import PullStreamPage from './PullStreamPage' import SidebarBrand from './components/SidebarBrand' import QualityGuardBanner from './components/QualityGuardBanner' import RelayLogPanel from './components/RelayLogPanel' @@ -86,7 +87,7 @@ function douyinRoomAndHref(douyinUrl: string | undefined, urlIni: string): { roo return { room, href } } -type NavId = 'record' | 'camera' | 'network' | 'system' +type NavId = 'record' | 'camera' | 'pull' | 'network' | 'system' function IconForbidden({ className }: { className?: string }) { return ( @@ -121,15 +122,17 @@ function IconLiveIdle({ className }: { className?: string }) { const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ { id: 'record', label: '直播', hint: '抖音转播推流与直播控制' }, - { id: 'camera', label: '摄像头', hint: '物理摄像头推流到 YouTube' }, + { id: 'camera', label: '摄像头', hint: '本机采集直推 YouTube' }, + { id: 'pull', label: '拉流', hint: 'RTMP / SRT / HLS 拉流转推' }, { id: 'network', label: '网络', hint: '连通与测速' }, { id: 'system', label: '系统', hint: '负载与网速' }, ] -/** 侧栏:直播 + 摄像头 + 网络 + 系统 */ +/** 侧栏:直播 + 摄像头 + 拉流 + 网络 + 系统 */ const SIDEBAR_NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ NAV_ITEMS.find((n) => n.id === 'record')!, NAV_ITEMS.find((n) => n.id === 'camera')!, + NAV_ITEMS.find((n) => n.id === 'pull')!, ...NAV_ITEMS.filter((n) => ['network', 'system'].includes(n.id)), ] @@ -289,7 +292,7 @@ export default function App() { const nav = raw === 'proxy' ? 'network' - : raw === 'record' || raw === 'camera' || raw === 'network' || raw === 'system' + : raw === 'record' || raw === 'camera' || raw === 'pull' || raw === 'network' || raw === 'system' ? raw : null if (nav) setNav(nav) @@ -1746,6 +1749,16 @@ export default function App() { /> )} + {nav === 'pull' && ( +
+ +
+ )} {nav === 'system' && (
diff --git a/electron/src/renderer/src/CameraLivePage.tsx b/electron/src/renderer/src/CameraLivePage.tsx index 129d3a2..803410d 100644 --- a/electron/src/renderer/src/CameraLivePage.tsx +++ b/electron/src/renderer/src/CameraLivePage.tsx @@ -3,18 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { apiFetch, apiFetchJson, apiUrl } from './api' import { useAdaptiveInterval, usePageVisible } from './hooks/useAdaptiveInterval' import { classifyRowActivity, statusPollMs } from './adaptivePoll' -import { - businessStatusLabel, - formatFfmpegSpeed, - statusPresentation, -} from './liveStatusLabels' +import { formatFfmpegSpeed } from './liveStatusLabels' import type { ControlStatusPayload, LiveDetailState, GpuCaps } from './types/live' import { liveDetailFromStatus } from './types/live' -import QualityGuardBanner from './components/QualityGuardBanner' -import LivePipeline from './components/LivePipeline' -import LiveYoutubeMonitor from './components/LiveYoutubeMonitor' import LivePulse from './components/ui/LivePulse' import CameraLocalPreview from './components/CameraLocalPreview' +import WorkerChecklist from './components/worker/WorkerChecklist' +import WorkerFlowStrip, { type FlowStep } from './components/worker/WorkerFlowStrip' +import WorkerMetrics from './components/worker/WorkerMetrics' import { formatControlOutput, mergeYoutubeOptionsForUi, @@ -23,7 +19,6 @@ import { } from './youtubeLiveUtils' const CAMERA_PROCESS = 'camera_youtube' -const CAMERA_RELAY_ID = 'camera' type CaptureDevice = { id: string; name: string; kind: 'video' | 'audio' } @@ -56,6 +51,48 @@ const DEFAULT_CONFIG: CameraConfig = { youtube_watch_url: '', } +function cameraFlowSteps( + live: LiveDetailState, + hasDevice: boolean, + hasKey: boolean, + useGpu: boolean, +): FlowStep[] { + const s = (live.businessStatus || '').trim().toLowerCase() + const pushing = live.isPushing || live.processStatus === 'online' + + let capture: FlowStep['state'] = hasDevice ? (pushing ? 'active' : 'pending') : 'warn' + let encode: FlowStep['state'] = 'idle' + let out: FlowStep['state'] = hasKey ? 'idle' : 'warn' + + if (s === 'streaming') { + capture = 'active' + encode = 'active' + out = 'active' + } else if (s === 'relay_slow' || s === 'source_stalled') { + capture = 'active' + encode = 'warn' + out = 'warn' + } else if (s === 'youtube_key_rejected') { + capture = hasDevice ? 'active' : 'warn' + encode = 'active' + out = 'error' + } else if (pushing) { + capture = 'active' + encode = 'pending' + out = 'pending' + } else if (hasDevice && hasKey) { + capture = 'pending' + encode = 'idle' + out = 'pending' + } + + return [ + { id: 'capture', label: '采集', title: '摄像头 / 麦克风', state: capture }, + { id: 'encode', label: '编码', title: useGpu ? 'NVENC' : 'x264', state: encode }, + { id: 'youtube', label: '输出', title: 'YouTube RTMPS', state: out }, + ] +} + export default function CameraLivePage({ backendReady, gpuCaps, @@ -67,7 +104,7 @@ export default function CameraLivePage({ const [deviceErr, setDeviceErr] = useState(null) const [deviceBusy, setDeviceBusy] = useState(false) const [config, setConfig] = useState(DEFAULT_CONFIG) - const [configStatus, setConfigStatus] = useState('就绪') + const [configStatus, setConfigStatus] = useState('') const [saveBusy, setSaveBusy] = useState(false) const [ytIniModel, setYtIniModel] = useState({ keys: [''], @@ -83,6 +120,7 @@ export default function CameraLivePage({ recentError: '', }) const [loadingAction, setLoadingAction] = useState(null) + const [logOpen, setLogOpen] = useState(false) const videoDevices = useMemo(() => devices.filter((d) => d.kind === 'video'), [devices]) const audioDevices = useMemo(() => devices.filter((d) => d.kind === 'audio'), [devices]) @@ -91,6 +129,7 @@ export default function CameraLivePage({ const keyConfigured = !!youtubeIniActiveKey(ytIniModel) const cameraConfigured = !!config.video_device.trim() const ready = keyConfigured && cameraConfigured && backendReady + const useGpu = !['否', '0', 'false', '关', 'off'].includes((config.use_gpu || '').toLowerCase()) const loadDevices = useCallback(async () => { setDeviceBusy(true) @@ -111,8 +150,7 @@ export default function CameraLivePage({ const loadConfig = useCallback(async () => { try { const data = await apiFetchJson<{ config?: Partial }>(apiUrl('/camera/config')) - const c = { ...DEFAULT_CONFIG, ...(data.config ?? {}) } - setConfig(c) + setConfig({ ...DEFAULT_CONFIG, ...(data.config ?? {}) }) } catch (e) { setConfigStatus(e instanceof Error ? e.message : String(e)) } @@ -188,8 +226,7 @@ export default function CameraLivePage({ const data = await apiFetchJson( `${apiUrl('/status')}?process=${encodeURIComponent(CAMERA_PROCESS)}`, ) - const ps = String(data.process_status ?? 'unknown') - setLiveDetail(liveDetailFromStatus(data, ps)) + setLiveDetail(liveDetailFromStatus(data, String(data.process_status ?? 'unknown'))) } catch { /* ignore */ } @@ -240,198 +277,88 @@ export default function CameraLivePage({ [backendReady, saveConfig, saveYoutubeKey, onControlNotice, refreshStatus], ) - const statusLine = statusPresentation(liveDetail.processStatus) - const businessLabel = businessStatusLabel(liveDetail.businessStatus, liveDetail.businessNote) + const checklist = useMemo( + () => [ + { + id: 'device', + label: '已选择摄像头', + ok: cameraConfigured, + hint: '从下方列表选择视频设备', + }, + { + id: 'encode', + label: `编码 ${config.width}×${config.height} @ ${config.fps}fps · ${config.bitrate}kbps`, + ok: cameraConfigured, + hint: '保存采集参数', + }, + { + id: 'key', + label: 'YouTube 串流密钥', + ok: keyConfigured, + hint: '填写右侧密钥后保存', + }, + { + id: 'backend', + label: '控制服务就绪', + ok: backendReady, + hint: '等待 web2 启动', + }, + ], + [cameraConfigured, config, keyConfigured, backendReady], + ) - const watchItems = useMemo( - () => [{ relayId: CAMERA_RELAY_ID, label: '摄像头线路', input: config.youtube_watch_url.trim() }], - [config.youtube_watch_url], + const flowSteps = useMemo( + () => cameraFlowSteps(liveDetail, cameraConfigured, keyConfigured, useGpu), + [liveDetail, cameraConfigured, keyConfigured, useGpu], ) return ( -
-
-

本地画面预览

-

- 推流前可先确认摄像头画面正常(与 FFmpeg 推流使用同一物理设备,预览走浏览器采集)。 +

+
+

摄像头直推

+

+ 本机采集 → FFmpeg 编码 → YouTube。与抖音转播独立进程,适合真人出镜、外接采集卡等场景。

- -
+ -
-
-
- 📷 物理摄像头直播 -

摄像头 → YouTube

-

- 使用本机摄像头与麦克风采集画面,经 FFmpeg 推送到 YouTube 直播间。与抖音转播线路独立,请勿同时开启冲突进程。 -

-
-
- - - {ready ? '可启动' : !keyConfigured ? '缺少密钥' : !cameraConfigured ? '未选摄像头' : '未就绪'} - - - {statusLine.line} - -
+
+
+
+ 画面预览 + + {config.video_device.trim() || '未选择设备'} +
+ +
+ +
- -
-

采集设备

-

点击刷新枚举本机摄像头与麦克风(依赖 FFmpeg DirectShow)。

- {deviceErr ?

{deviceErr}

: null} -
- - - - -
-
+
- -
-
{configStatus}
-
- -
-

YouTube 推流密钥

-

与抖音转播共用 `config/youtube.ini` 中的串流密钥。

- - setYtIniModel((m) => ({ ...m, keys: [e.target.value], activeIndex: 0 })) - } - /> -
- -
-
{youtubeStatus}
- -
- - {controlNotice ? ( -
-

操作反馈

-
{controlNotice}
-
- ) : null} - -
-

摄像头直播开关

-

- 进程 {CAMERA_PROCESS} · {businessLabel} - {liveDetail.ffmpegSpeed != null ? ` · 速度 ${formatFfmpegSpeed(liveDetail.ffmpegSpeed)}` : ''} -

-
- +
+ +
+ +
+
+

采集设备

+

FFmpeg DirectShow 枚举本机摄像头与麦克风。

+ {deviceErr ?

{deviceErr}

: null} +
+ + +
+
+
-
-

运行日志

-
{liveDetail.recentLog?.trim() || '(暂无)'}
- {liveDetail.recentError?.trim() ? ( -
{liveDetail.recentError}
- ) : null} +
+

编码参数

+
+ + + + +
+
+ +
+ {configStatus ?

{configStatus}

: null}
+ +
+

YouTube 输出

+ +
+ +
+ {youtubeStatus ?

{youtubeStatus}

: null} + +
+
+ +
setLogOpen((e.target as HTMLDetailsElement).open)} + > + FFmpeg 运行日志 +
{liveDetail.recentLog?.trim() || '(暂无)'}
+ {liveDetail.recentError?.trim() ? ( +
{liveDetail.recentError}
+ ) : null} +
) } diff --git a/electron/src/renderer/src/PullStreamPage.tsx b/electron/src/renderer/src/PullStreamPage.tsx new file mode 100644 index 0000000..1affe85 --- /dev/null +++ b/electron/src/renderer/src/PullStreamPage.tsx @@ -0,0 +1,661 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { apiFetch, apiFetchJson, apiUrl } from './api' +import { useAdaptiveInterval, usePageVisible } from './hooks/useAdaptiveInterval' +import { classifyRowActivity, statusPollMs } from './adaptivePoll' +import { formatFfmpegSpeed } from './liveStatusLabels' +import type { ControlStatusPayload, LiveDetailState, GpuCaps } from './types/live' +import { liveDetailFromStatus } from './types/live' +import LivePulse from './components/ui/LivePulse' +import WorkerChecklist from './components/worker/WorkerChecklist' +import WorkerFlowStrip, { type FlowStep } from './components/worker/WorkerFlowStrip' +import WorkerMetrics from './components/worker/WorkerMetrics' +import { + formatControlOutput, + mergeYoutubeOptionsForUi, + youtubeIniActiveKey, + type YoutubeIniModel, +} from './youtubeLiveUtils' + +const PULL_PROCESS = 'pull_stream_youtube' + +type PullStreamConfig = { + source_url: string + protocol: string + transcode: string + bitrate: string + use_gpu: string + srt_latency_ms: string + reconnect: string + youtube_watch_url: string + label: string + extra_input_args: string +} + +type ProtocolPreset = { + id: string + label: string + example: string + hint: string +} + +type Props = { + backendReady: boolean + gpuCaps: GpuCaps | null + controlNotice?: string + onControlNotice: (text: string) => void +} + +const DEFAULT_CONFIG: PullStreamConfig = { + source_url: '', + protocol: 'auto', + transcode: 'transcode', + bitrate: '4500', + use_gpu: '是', + srt_latency_ms: '200', + reconnect: '是', + youtube_watch_url: '', + label: '', + extra_input_args: '', +} + +const PROTOCOL_OPTIONS = [ + { value: 'auto', label: '自动识别' }, + { value: 'rtmp', label: 'RTMP' }, + { value: 'rtmps', label: 'RTMPS' }, + { value: 'srt', label: 'SRT' }, + { value: 'hls', label: 'HLS (m3u8)' }, + { value: 'http-flv', label: 'HTTP-FLV' }, + { value: 'webrtc', label: 'WebRTC (WHEP)' }, + { value: 'udp', label: 'UDP / MPEG-TS' }, +] + +function detectProtocol(url: string): string { + const low = url.trim().toLowerCase() + if (!low) return '' + if (low.startsWith('rtmps://')) return 'rtmps' + if (low.startsWith('rtmp://')) return 'rtmp' + if (low.startsWith('srt://')) return 'srt' + if (low.startsWith('whep://') || low.startsWith('webrtc://')) return 'webrtc' + if (low.startsWith('udp://')) return 'udp' + if (low.includes('.m3u8')) return 'hls' + if (low.includes('.flv')) return 'http-flv' + if (low.startsWith('http://') || low.startsWith('https://')) return 'http-flv' + return '' +} + +function protocolLabel(id: string): string { + return PROTOCOL_OPTIONS.find((o) => o.value === id)?.label ?? (id || '未知') +} + +function pullFlowSteps( + live: LiveDetailState, + hasSource: boolean, + hasKey: boolean, + transcode: string, + protocol: string, +): FlowStep[] { + const s = (live.businessStatus || '').trim().toLowerCase() + const pushing = live.isPushing || live.processStatus === 'online' + const modeTitle = transcode === 'copy' ? '流复制' : '转码 H.264' + + let pull: FlowStep['state'] = hasSource ? (pushing ? 'active' : 'pending') : 'warn' + let process: FlowStep['state'] = 'idle' + let out: FlowStep['state'] = hasKey ? 'idle' : 'warn' + + if (s === 'streaming') { + pull = 'active' + process = 'active' + out = 'active' + } else if (s === 'relay_slow' || s === 'source_stalled') { + pull = 'warn' + process = 'warn' + out = pushing ? 'warn' : 'idle' + } else if (s === 'waiting_source' || s === 'monitoring') { + pull = 'pending' + process = 'idle' + out = hasKey ? 'idle' : 'warn' + } else if (s === 'youtube_key_rejected') { + pull = 'active' + process = 'active' + out = 'error' + } else if (pushing) { + pull = 'active' + process = 'pending' + out = 'pending' + } else if (hasSource && hasKey) { + pull = 'pending' + process = 'idle' + out = 'pending' + } + + return [ + { id: 'pull', label: '拉流', title: protocol ? protocolLabel(protocol) : '等待地址', state: pull }, + { id: 'process', label: '处理', title: modeTitle, state: process }, + { id: 'youtube', label: '输出', title: 'YouTube RTMPS', state: out }, + ] +} + +export default function PullStreamPage({ + backendReady, + gpuCaps, + controlNotice, + onControlNotice, +}: Props) { + const pageVisible = usePageVisible() + const [config, setConfig] = useState(DEFAULT_CONFIG) + const [configStatus, setConfigStatus] = useState('') + const [saveBusy, setSaveBusy] = useState(false) + const [presets, setPresets] = useState([]) + const [advancedOpen, setAdvancedOpen] = useState(false) + const [ytIniModel, setYtIniModel] = useState({ + keys: [''], + activeIndex: 0, + options: {}, + header: '', + }) + const [youtubeStatus, setYoutubeStatus] = useState('') + const [saveKeyBusy, setSaveKeyBusy] = useState(false) + const [liveDetail, setLiveDetail] = useState({ + processStatus: 'unknown', + recentLog: '', + recentError: '', + }) + const [loadingAction, setLoadingAction] = useState(null) + const [logOpen, setLogOpen] = useState(false) + + const keyConfigured = !!youtubeIniActiveKey(ytIniModel) + const sourceConfigured = !!config.source_url.trim() + const resolvedProtocol = + config.protocol === 'auto' ? detectProtocol(config.source_url) : config.protocol + const protocolOk = !!resolvedProtocol + const ready = keyConfigured && sourceConfigured && protocolOk && backendReady + const isLive = liveDetail.processStatus === 'online' + const pmBusy = loadingAction !== null + const useGpu = !['否', '0', 'false', '关', 'off'].includes((config.use_gpu || '').toLowerCase()) + + const loadConfig = useCallback(async () => { + try { + const data = await apiFetchJson<{ config?: Partial }>(apiUrl('/pull_stream/config')) + setConfig({ ...DEFAULT_CONFIG, ...(data.config ?? {}) }) + } catch (e) { + setConfigStatus(e instanceof Error ? e.message : String(e)) + } + }, []) + + const loadProtocols = useCallback(async () => { + try { + const data = await apiFetchJson<{ protocols?: ProtocolPreset[] }>(apiUrl('/pull_stream/protocols')) + setPresets(data.protocols ?? []) + } catch { + /* ignore */ + } + }, []) + + const loadYoutubeIni = useCallback(async () => { + try { + const data = await apiFetchJson<{ + keys?: string[] + activeIndex?: number + options?: Record + header?: string + }>(apiUrl('/youtube/ini_model')) + setYtIniModel({ + keys: data.keys?.length ? data.keys.map(String) : [''], + activeIndex: data.activeIndex ?? 0, + options: mergeYoutubeOptionsForUi(data.options ?? {}, gpuCaps), + header: data.header ?? '', + }) + } catch { + /* ignore */ + } + }, [gpuCaps]) + + useEffect(() => { + void loadConfig() + void loadProtocols() + void loadYoutubeIni() + }, [loadConfig, loadProtocols, loadYoutubeIni]) + + const saveConfig = useCallback(async () => { + setSaveBusy(true) + try { + const data = await apiFetchJson<{ message?: string }>(apiUrl('/pull_stream/config'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }) + setConfigStatus(data.message || '已保存') + } catch (e) { + setConfigStatus(e instanceof Error ? e.message : String(e)) + } finally { + setSaveBusy(false) + } + }, [config]) + + const saveYoutubeKey = useCallback(async () => { + setSaveKeyBusy(true) + try { + const res = await apiFetch(apiUrl('/youtube/ini_model'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + keys: [(ytIniModel.keys[0] ?? '').trim()], + activeIndex: 0, + options: ytIniModel.options, + }), + }) + const data = (await res.json()) as { error?: string; message?: string } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + setYoutubeStatus(data.message || '密钥已保存') + await loadYoutubeIni() + } catch (e) { + setYoutubeStatus(e instanceof Error ? e.message : String(e)) + } finally { + setSaveKeyBusy(false) + } + }, [ytIniModel, loadYoutubeIni]) + + const refreshStatus = useCallback(async () => { + if (!backendReady) return + try { + const data = await apiFetchJson( + `${apiUrl('/status')}?process=${encodeURIComponent(PULL_PROCESS)}`, + ) + setLiveDetail(liveDetailFromStatus(data, String(data.process_status ?? 'unknown'))) + } catch { + /* ignore */ + } + }, [backendReady]) + + useEffect(() => { + void refreshStatus() + }, [refreshStatus]) + + const pollMs = useMemo( + () => + statusPollMs({ + activity: classifyRowActivity({ + process_status: liveDetail.processStatus, + business_status: liveDetail.businessStatus, + is_pushing: liveDetail.isPushing, + }), + pageVisible, + }), + [liveDetail.processStatus, liveDetail.businessStatus, liveDetail.isPushing, pageVisible], + ) + useAdaptiveInterval(() => void refreshStatus(), pollMs, backendReady && pollMs > 0) + + const runAction = useCallback( + async (action: 'start' | 'stop' | 'restart') => { + if (!backendReady) return + setLoadingAction(action) + try { + if (action === 'start' || action === 'restart') { + await saveYoutubeKey() + await saveConfig() + if (action === 'restart') { + await apiFetchJson(`${apiUrl('/stop')}?process=${encodeURIComponent(PULL_PROCESS)}`) + await new Promise((r) => setTimeout(r, 800)) + } + } + const data = await apiFetchJson( + `${apiUrl(`/${action}`)}?process=${encodeURIComponent(PULL_PROCESS)}`, + ) + onControlNotice(formatControlOutput(data as Record)) + await refreshStatus() + } catch (e) { + onControlNotice(`操作失败:${e instanceof Error ? e.message : String(e)}`) + } finally { + setLoadingAction(null) + } + }, + [backendReady, saveConfig, saveYoutubeKey, onControlNotice, refreshStatus], + ) + + const applyPreset = useCallback((preset: ProtocolPreset) => { + setConfig((c) => ({ + ...c, + protocol: preset.id, + source_url: c.source_url.trim() ? c.source_url : preset.example, + })) + }, []) + + const checklist = useMemo( + () => [ + { + id: 'source', + label: '拉流地址已填写', + ok: sourceConfigured, + hint: '输入 RTMP / SRT / m3u8 等 URL', + }, + { + id: 'protocol', + label: protocolOk + ? `协议:${protocolLabel(resolvedProtocol)}` + : '协议未能识别', + ok: protocolOk, + hint: '手动选择协议或修正 URL', + }, + { + id: 'key', + label: 'YouTube 串流密钥', + ok: keyConfigured, + hint: '填写密钥并保存', + }, + { + id: 'backend', + label: '控制服务就绪', + ok: backendReady, + hint: '等待 web2 启动', + }, + ], + [sourceConfigured, protocolOk, resolvedProtocol, keyConfigured, backendReady], + ) + + const flowSteps = useMemo( + () => + pullFlowSteps( + liveDetail, + sourceConfigured, + keyConfigured, + config.transcode, + resolvedProtocol, + ), + [liveDetail, sourceConfigured, keyConfigured, config.transcode, resolvedProtocol], + ) + + return ( +
+
+

外部拉流转推

+

+ 从 SRS、OBS、其他节点拉取已有流,复制或转码后推到 YouTube。适合已有 RTMP/SRT/HLS 管线、无需抖音解析的场景。 +

+
+ +
+
+ + +
+ + {protocolOk ? protocolLabel(resolvedProtocol) : '待识别协议'} + + {config.label.trim() ? ( + {config.label} + ) : null} + + {config.transcode === 'copy' ? '流复制' : `转码 ${config.bitrate}k`} + {config.transcode !== 'copy' ? (useGpu ? ' · NVENC' : ' · x264') : ''} + +
+ + {presets.length > 0 ? ( +
+ {presets.map((p) => ( + + ))} +
+ ) : null} +
+ + +
+ +
+
+

处理策略

+
+ + + {config.transcode !== 'copy' ? ( + <> + + + + ) : ( +

+ 流复制要求源已是 H.264 + AAC,否则 YouTube 可能拒收。 +

+ )} +
+
+ +
+ {configStatus ?

{configStatus}

: null} +
+ +
+

YouTube 输出

+ +
+ +
+ {youtubeStatus ?

{youtubeStatus}

: null} +
+ +
+ + {advancedOpen ? ( +
+ +
+ + +
+ + +
+ ) : null} +
+
+ +
setLogOpen((e.target as HTMLDetailsElement).open)} + > + FFmpeg 运行日志 +
{liveDetail.recentLog?.trim() || '(暂无)'}
+ {liveDetail.recentError?.trim() ? ( +
{liveDetail.recentError}
+ ) : null} +
+
+ ) +} diff --git a/electron/src/renderer/src/components/icons/NavIcons.tsx b/electron/src/renderer/src/components/icons/NavIcons.tsx index e7af2d7..0f5070b 100644 --- a/electron/src/renderer/src/components/icons/NavIcons.tsx +++ b/electron/src/renderer/src/components/icons/NavIcons.tsx @@ -1,4 +1,4 @@ -type NavId = 'record' | 'camera' | 'network' | 'system' +type NavId = 'record' | 'camera' | 'pull' | 'network' | 'system' type IconProps = { className?: string } @@ -31,6 +31,16 @@ function IconCamera({ className }: IconProps) { ) } +function IconPull({ className }: IconProps) { + return ( + + + + + + ) +} + function IconNetwork({ className }: IconProps) { return ( @@ -56,6 +66,7 @@ function IconSystem({ className }: IconProps) { const NAV_ICON_MAP: Record JSX.Element> = { record: IconRecord, camera: IconCamera, + pull: IconPull, network: IconNetwork, system: IconSystem, } @@ -65,4 +76,4 @@ export function NavIcon({ id, className }: { id: NavId; className?: string }) { return } -export { IconRecord, IconCamera, IconNetwork, IconSystem } +export { IconRecord, IconCamera, IconPull, IconNetwork, IconSystem } diff --git a/electron/src/renderer/src/components/worker/WorkerChecklist.tsx b/electron/src/renderer/src/components/worker/WorkerChecklist.tsx new file mode 100644 index 0000000..abb6221 --- /dev/null +++ b/electron/src/renderer/src/components/worker/WorkerChecklist.tsx @@ -0,0 +1,27 @@ +type Item = { + id: string + label: string + ok: boolean + hint?: string +} + +type Props = { + items: Item[] +} + +export default function WorkerChecklist({ items }: Props) { + const allOk = items.every((i) => i.ok) + return ( +
    + {items.map((item) => ( +
  • + + {item.ok ? '✓' : '○'} + + {item.label} + {!item.ok && item.hint ? {item.hint} : null} +
  • + ))} +
+ ) +} diff --git a/electron/src/renderer/src/components/worker/WorkerFlowStrip.tsx b/electron/src/renderer/src/components/worker/WorkerFlowStrip.tsx new file mode 100644 index 0000000..c3a5510 --- /dev/null +++ b/electron/src/renderer/src/components/worker/WorkerFlowStrip.tsx @@ -0,0 +1,34 @@ +export type FlowStepState = 'idle' | 'pending' | 'active' | 'warn' | 'error' + +export type FlowStep = { + id: string + label: string + title: string + state: FlowStepState +} + +type Props = { + steps: FlowStep[] + className?: string +} + +export default function WorkerFlowStrip({ steps, className = '' }: Props) { + return ( +
+ {steps.map((step, idx) => ( +
+ {idx > 0 ? ( +
+ ) : null} +
+ {step.label} + {step.title} +
+
+ ))} +
+ ) +} diff --git a/electron/src/renderer/src/components/worker/WorkerMetrics.tsx b/electron/src/renderer/src/components/worker/WorkerMetrics.tsx new file mode 100644 index 0000000..3ba4a61 --- /dev/null +++ b/electron/src/renderer/src/components/worker/WorkerMetrics.tsx @@ -0,0 +1,48 @@ +import { businessStatusLabel, formatFfmpegSpeed } from '../../liveStatusLabels' +import type { LiveDetailState } from '../../types/live' +import LivePulse from '../ui/LivePulse' + +type Props = { + live: LiveDetailState + processName: string + isOnline: boolean +} + +export default function WorkerMetrics({ live, processName, isOnline }: Props) { + const businessLabel = businessStatusLabel(live.businessStatus, live.businessNote) + const speedWarn = live.ffmpegSpeedLow || live.ffmpegProgressStalled + + return ( +
+
+ + + {isOnline ? '推流中' : '未推流'} + + {businessLabel} +
+
+
+
进程
+
{processName}
+
+
+
FFmpeg 速度
+
+ {live.ffmpegSpeed != null ? formatFfmpegSpeed(live.ffmpegSpeed) : '—'} + {live.ffmpegSpeedLow ? '(偏低)' : ''} + {live.ffmpegProgressStalled ? '(停滞)' : ''} +
+
+
+
帧序号
+
{live.ffmpegFrame != null ? live.ffmpegFrame : '—'}
+
+
+
Workers
+
{live.ffmpegPids?.length ? live.ffmpegPids.length : '—'}
+
+
+
+ ) +} diff --git a/pull_stream_youtube.py b/pull_stream_youtube.py new file mode 100644 index 0000000..c7ee975 --- /dev/null +++ b/pull_stream_youtube.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +"""Pull stream (RTMP / SRT / HLS / FLV / WHEP) → YouTube RTMPS relay.""" + +from __future__ import annotations + +import configparser +import os +import platform +import signal +import subprocess +import sys +import time +from pathlib import Path + +from web2_proxy_config import merge_child_env +from web2_pull_stream import build_pull_input_args, load_pull_stream_config, resolve_protocol + +BASE_DIR = Path(__file__).resolve().parent +CONFIG_DIR = BASE_DIR / "config" +YOUTUBE_INI = CONFIG_DIR / "youtube.ini" + +RUNNING = True + + +def _on_signal(*_args): + global RUNNING + RUNNING = False + + +signal.signal(signal.SIGTERM, _on_signal) +signal.signal(signal.SIGINT, _on_signal) + + +def _load_youtube_key() -> tuple[str, str]: + rtmps = "rtmps://a.rtmp.youtube.com/live2/" + if not YOUTUBE_INI.exists(): + return "", rtmps + cp = configparser.ConfigParser() + cp.read(YOUTUBE_INI, encoding="utf-8") + if not cp.has_section("youtube"): + return "", rtmps + key = "" + for name, val in cp.items("youtube"): + v = (val or "").strip() + if name in ("key", "stream_key") and v and not v.startswith("#"): + key = v + break + if key.startswith("rtmp://") or key.startswith("rtmps://"): + return key, key + return key, rtmps + key if key else "" + + +def _pick_nvenc(force_sw: bool) -> tuple[str, list[str]]: + if force_sw: + return "libx264", ["-preset", "veryfast", "-tune", "zerolatency", "-profile:v", "high"] + try: + if platform.system().lower() not in ("windows", "linux"): + raise RuntimeError("skip") + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5) + if check.returncode != 0: + raise RuntimeError("no nvidia") + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5) + if "h264_nvenc" in (enc.stdout or ""): + print("[INFO] 使用 NVENC 硬件编码") + return "h264_nvenc", ["-profile:v", "high", "-tune", "ll", "-rc", "cbr"] + except Exception: + pass + return "libx264", ["-preset", "veryfast", "-tune", "zerolatency", "-profile:v", "high"] + + +def _build_cmd(cfg: dict[str, str], youtube_url: str) -> list[str]: + input_args, proto = build_pull_input_args(cfg) + transcode = (cfg.get("transcode") or "transcode").lower() + br = int(cfg.get("bitrate") or "4500") + b_v = f"{br}k" + maxrate = f"{min(int(br * 1.2), 8000)}k" + bufsize = f"{br * 2}k" + force_sw = (cfg.get("use_gpu") or "").lower() in ("否", "0", "false", "无", "关", "off") + + print(f"[INFO] 拉流协议: {proto}") + print(f"[INFO] 源地址: {(cfg.get('source_url') or '').strip()}") + + if transcode == "copy": + output_args = [ + "-c:v", + "copy", + "-c:a", + "copy", + "-bsf:a", + "aac_adtstoasc", + "-f", + "flv", + youtube_url, + ] + return ["ffmpeg", *input_args, *output_args] + + codec, vopts = _pick_nvenc(force_sw) + fps = 30 + output_args = [ + "-c:v", + codec, + *vopts, + "-pix_fmt", + "yuv420p", + "-b:v", + b_v, + "-maxrate", + maxrate, + "-bufsize", + bufsize, + "-g", + str(fps * 2), + "-c:a", + "aac", + "-b:a", + "128k", + "-ar", + "44100", + "-f", + "flv", + youtube_url, + ] + return ["ffmpeg", *input_args, *output_args] + + +def main() -> None: + print("[INFO] 拉流 → YouTube 转播进程已启动") + proc: subprocess.Popen | None = None + while RUNNING: + cfg = load_pull_stream_config(CONFIG_DIR) + key, youtube_url = _load_youtube_key() + if not key: + print("[WARN] 请在 config/youtube.ini 配置 YouTube 串流密钥 (key=…)") + time.sleep(5) + continue + if not (cfg.get("source_url") or "").strip(): + print("[WARN] 请在拉流配置中填写 source_url") + time.sleep(5) + continue + if resolve_protocol(cfg) == "auto": + print("[WARN] 无法识别拉流协议,请在配置中指定 protocol") + time.sleep(5) + continue + cmd = _build_cmd(cfg, youtube_url) + print("[INFO] 启动 FFmpeg 拉流转播…") + print("[INFO] ffmpeg " + " ".join(cmd[1:])) + env = merge_child_env(os.environ.copy()) + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + env=env, + cwd=str(BASE_DIR), + ) + except Exception as e: + print(f"[ERROR] 无法启动 FFmpeg: {e}") + time.sleep(5) + continue + + assert proc.stdout is not None + while RUNNING: + line = proc.stdout.readline() + if not line: + break + print(line.rstrip()) + code = proc.wait() + proc = None + if not RUNNING: + break + print(f"[WARN] FFmpeg 退出 code={code},5 秒后重试…") + time.sleep(5) + + if proc and proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout=5) + except Exception: + try: + proc.kill() + except Exception: + pass + print("[INFO] 拉流转播进程已结束") + + +if __name__ == "__main__": + main() diff --git a/web2.py b/web2.py index 679c1ac..78a957e 100644 --- a/web2.py +++ b/web2.py @@ -70,6 +70,12 @@ from web2_relay_pro import ( from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini, validate_youtube_keys from web2_host_caps import gpu_status_payload, machine_summary_payload from web2_camera import camera_config_ready, list_capture_devices, load_camera_config, save_camera_config +from web2_pull_stream import ( + load_pull_stream_config, + protocol_help, + pull_stream_config_ready, + save_pull_stream_config, +) from web2_pocketbase import REQUIRE_PB, pb_auth_refresh_and_validate @@ -96,6 +102,7 @@ def _label_short(prefix: str) -> str: "tiktok": "TikTok", "youtube": "YouTube", "camera": "摄像头", + "pull": "拉流", "obs": "OBS", "web": "Web 控制台", }.get(prefix, prefix) @@ -119,6 +126,10 @@ def discover_script_entries() -> tuple: if path.is_file() and path.resolve() != web_path: entries.append({"script": path.name, "label": _label_short("camera")}) + for path in sorted(base.glob("pull_stream*.py")): + if path.is_file() and path.resolve() != web_path: + entries.append({"script": path.name, "label": _label_short("pull")}) + for path in sorted(base.glob("obs*.sh")): if path.is_file(): entries.append({"script": path.name, "label": _label_short("obs")}) @@ -147,6 +158,10 @@ def is_camera_script(script: str) -> bool: return script.endswith(".py") and _stem(script).startswith("camera") +def is_pull_stream_script(script: str) -> bool: + return script.endswith(".py") and _stem(script).startswith("pull_stream") + + def is_youtube_script(script: str) -> bool: return script.endswith(".py") and _stem(script).startswith("youtube") @@ -855,6 +870,16 @@ def _camera_ready_message(process: str) -> str | None: return camera_config_ready(CONFIG_DIR) +def _pull_stream_ready_message(process: str) -> str | None: + if not is_pull_stream_script(script_for_pm2(process) or ""): + return None + return pull_stream_config_ready(CONFIG_DIR) + + +def _stream_worker_ready_message(process: str) -> str | None: + return _camera_ready_message(process) or _pull_stream_ready_message(process) + + @app.get("/camera/devices") async def camera_devices(): return list_capture_devices() @@ -880,6 +905,31 @@ async def camera_config_post(request: Request): return {"message": msg, "config": load_camera_config(CONFIG_DIR)} +@app.get("/pull_stream/config") +async def pull_stream_config_get(): + return {"config": load_pull_stream_config(CONFIG_DIR)} + + +@app.get("/pull_stream/protocols") +async def pull_stream_protocols(): + return {"protocols": protocol_help()} + + +@app.post("/pull_stream/config") +async def pull_stream_config_post(request: Request): + try: + data = await request.json() + except Exception: + return JSONResponse({"error": "无效的 JSON"}, status_code=400) + if not isinstance(data, dict): + return JSONResponse({"error": "无效数据"}, status_code=400) + patch = data.get("config") if isinstance(data.get("config"), dict) else data + ok, msg = save_pull_stream_config(CONFIG_DIR, patch) + if not ok: + return JSONResponse({"error": msg}, status_code=400) + return {"message": msg, "config": load_pull_stream_config(CONFIG_DIR)} + + @app.get("/start") async def start(request: Request, process: str = Query(..., description="进程名")): if REQUIRE_PB: @@ -902,7 +952,7 @@ async def start(request: Request, process: str = Query(..., description="进程 if not ok: return JSONResponse({"output": msg}, status_code=400) await _stop_legacy_youtube_if_online() - cam_msg = _camera_ready_message(process) + cam_msg = _stream_worker_ready_message(process) if cam_msg: return JSONResponse({"output": cam_msg}, status_code=400) output = await control_start(process) @@ -963,7 +1013,7 @@ async def restart(request: Request, process: str = Query(..., description="进 if not ok: return JSONResponse({"output": f"{stop_output}\n{msg}"}, status_code=400) await _stop_legacy_youtube_if_online() - cam_msg = _camera_ready_message(process) + cam_msg = _stream_worker_ready_message(process) if cam_msg: return JSONResponse({"output": f"{stop_output}\n{cam_msg}"}, status_code=400) start_output = await control_start(process) diff --git a/web2_process_runtime.py b/web2_process_runtime.py index 77b4426..afb00c4 100644 --- a/web2_process_runtime.py +++ b/web2_process_runtime.py @@ -22,7 +22,7 @@ def normalize_log_text(text: str) -> str: def process_family(process_name: str) -> str: low = (process_name or "").strip().lower() - if low.startswith("camera") or low.startswith("douyin_youtube"): + if low.startswith("camera") or low.startswith("douyin_youtube") or low.startswith("pull_stream"): return "youtube" if low.startswith("youtube"): return "youtube" diff --git a/web2_pull_stream.py b/web2_pull_stream.py new file mode 100644 index 0000000..72337ec --- /dev/null +++ b/web2_pull_stream.py @@ -0,0 +1,291 @@ +"""Pull-stream relay config and FFmpeg input helpers (RTMP / SRT / HLS / FLV / WebRTC-WHEP).""" + +from __future__ import annotations + +import configparser +import re +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +PULL_STREAM_SECTION = "pull_stream" + +PROTOCOL_CHOICES = ("auto", "rtmp", "rtmps", "srt", "hls", "http-flv", "webrtc", "udp") + + +def pull_stream_ini_path(config_dir: Path) -> Path: + return config_dir / "pull_stream.ini" + + +def default_pull_stream_config() -> dict[str, str]: + return { + "source_url": "", + "protocol": "auto", + "transcode": "transcode", + "bitrate": "4500", + "use_gpu": "是", + "srt_latency_ms": "200", + "reconnect": "是", + "youtube_watch_url": "", + "label": "", + "extra_input_args": "", + } + + +def load_pull_stream_config(config_dir: Path) -> dict[str, str]: + p = pull_stream_ini_path(config_dir) + out = default_pull_stream_config() + if not p.exists(): + return out + cp = configparser.ConfigParser() + try: + cp.read(p, encoding="utf-8") + except Exception: + return out + if not cp.has_section(PULL_STREAM_SECTION): + return out + for key in out: + if cp.has_option(PULL_STREAM_SECTION, key): + out[key] = str(cp.get(PULL_STREAM_SECTION, key) or "").strip() + return out + + +def save_pull_stream_config(config_dir: Path, patch: dict[str, Any]) -> tuple[bool, str]: + config_dir = Path(config_dir) + config_dir.mkdir(parents=True, exist_ok=True) + cur = load_pull_stream_config(config_dir) + for k, v in patch.items(): + if k in cur: + cur[k] = str(v or "").strip() + proto = (cur.get("protocol") or "auto").lower() + if proto not in PROTOCOL_CHOICES: + cur["protocol"] = "auto" + transcode = (cur.get("transcode") or "transcode").lower() + if transcode not in ("copy", "transcode"): + cur["transcode"] = "transcode" + cp = configparser.ConfigParser() + cp[PULL_STREAM_SECTION] = cur + p = pull_stream_ini_path(config_dir) + try: + with p.open("w", encoding="utf-8") as f: + cp.write(f) + return True, "拉流配置已保存" + except Exception as e: + return False, f"保存失败: {e}" + + +def detect_protocol_from_url(url: str) -> str: + raw = (url or "").strip() + low = raw.lower() + if not low: + return "auto" + if low.startswith("rtmps://"): + return "rtmps" + if low.startswith("rtmp://"): + return "rtmp" + if low.startswith("srt://"): + return "srt" + if low.startswith("whep://") or low.startswith("webrtc://"): + return "webrtc" + if low.startswith("udp://"): + return "udp" + if ".m3u8" in low or low.endswith("/m3u8"): + return "hls" + if ".flv" in low: + return "http-flv" + if low.startswith("http://") or low.startswith("https://"): + return "http-flv" + return "auto" + + +def resolve_protocol(cfg: dict[str, str]) -> str: + chosen = (cfg.get("protocol") or "auto").strip().lower() + if chosen and chosen != "auto": + return chosen + return detect_protocol_from_url(cfg.get("source_url") or "") + + +def pull_stream_config_ready(config_dir: Path) -> str | None: + cfg = load_pull_stream_config(config_dir) + url = str(cfg.get("source_url") or "").strip() + if not url: + return "请先填写拉流地址(RTMP / SRT / HLS / FLV 等)" + proto = resolve_protocol(cfg) + if proto == "auto": + return "无法识别协议,请手动选择协议类型" + yp = Path(config_dir) / "youtube.ini" + if not yp.exists(): + return "请先在 YouTube 配置中填写串流密钥" + cp = configparser.ConfigParser() + try: + cp.read(yp, encoding="utf-8") + except Exception: + return "无法读取 youtube.ini" + if not cp.has_section("youtube"): + return "请先在 YouTube 配置中填写串流密钥" + for name, val in cp.items("youtube"): + v = (val or "").strip() + if name in ("key", "stream_key") and v and not v.startswith("#"): + return None + return "请先在 YouTube 配置中填写串流密钥" + + +def _split_extra_args(text: str) -> list[str]: + raw = (text or "").strip() + if not raw: + return [] + try: + import shlex + + return shlex.split(raw, posix=False) + except Exception: + return [x for x in re.split(r"\s+", raw) if x] + + +def build_pull_input_args(cfg: dict[str, str]) -> tuple[list[str], str]: + """Return FFmpeg input args (before -i URL) and resolved protocol.""" + url = (cfg.get("source_url") or "").strip() + proto = resolve_protocol(cfg) + reconnect_on = (cfg.get("reconnect") or "是").lower() not in ("否", "0", "false", "关", "off") + latency = int(cfg.get("srt_latency_ms") or "200") + extra = _split_extra_args(cfg.get("extra_input_args") or "") + + common: list[str] = ["-hide_banner", "-loglevel", "info"] + if reconnect_on: + common.extend( + [ + "-reconnect", + "1", + "-reconnect_streamed", + "1", + "-reconnect_delay_max", + "5", + ] + ) + + if proto in ("rtmp", "rtmps"): + input_args = [ + *common, + "-fflags", + "+genpts", + "-flags", + "low_delay", + "-rw_timeout", + "15000000", + "-i", + url, + ] + elif proto == "srt": + input_args = [ + *common, + "-fflags", + "nobuffer", + "-flags", + "low_delay", + "-timeout", + "10000000", + "-i", + url if "latency" in url.lower() else f"{url}{'&' if '?' in url else '?'}latency={latency}", + ] + elif proto == "hls": + input_args = [ + *common, + "-fflags", + "nobuffer", + "-flags", + "low_delay", + "-live_start_index", + "-1", + "-i", + url, + ] + elif proto in ("http-flv", "udp"): + input_args = [ + *common, + "-fflags", + "nobuffer", + "-flags", + "low_delay", + "-i", + url, + ] + elif proto == "webrtc": + input_args = [ + *common, + "-fflags", + "nobuffer", + "-flags", + "low_delay", + "-allowed_extensions", + "ALL", + "-protocol_whitelist", + "file,http,https,tcp,tls,udp,rtp", + "-i", + url, + ] + else: + input_args = [*common, "-i", url] + + if extra: + input_args = input_args[:1] + extra + input_args[1:] + return input_args, proto + + +def protocol_help() -> list[dict[str, str]]: + return [ + { + "id": "rtmp", + "label": "RTMP", + "example": "rtmp://127.0.0.1/live/stream", + "hint": "SRS / OBS / nginx-rtmp 等标准 RTMP 拉流", + }, + { + "id": "rtmps", + "label": "RTMPS", + "example": "rtmps://live.example.com/app/key", + "hint": "TLS 加密的 RTMP", + }, + { + "id": "srt", + "label": "SRT", + "example": "srt://127.0.0.1:10080?mode=caller&streamid=#!::r=live/obs,m=publish", + "hint": "低延迟 SRT;SRS 常用 caller 模式", + }, + { + "id": "hls", + "label": "HLS", + "example": "https://example.com/live/stream.m3u8", + "hint": "HTTP Live Streaming(m3u8)", + }, + { + "id": "http-flv", + "label": "HTTP-FLV", + "example": "http://127.0.0.1:8080/live/stream.flv", + "hint": "SRS HTTP-FLV 等", + }, + { + "id": "webrtc", + "label": "WebRTC (WHEP)", + "example": "whep://your-server/index/api/whep?app=live&stream=obs", + "hint": "需 FFmpeg 支持 WHEP;否则请经 SRS 转为 RTMP/SRT 再拉", + }, + { + "id": "udp", + "label": "UDP / MPEG-TS", + "example": "udp://@:1234", + "hint": "MPEG-TS over UDP", + }, + ] + + +def source_url_looks_valid(url: str) -> bool: + raw = (url or "").strip() + if not raw: + return False + if raw.startswith(("rtmp://", "rtmps://", "srt://", "udp://", "whep://", "webrtc://")): + return True + try: + p = urlparse(raw) + return p.scheme in ("http", "https") and bool(p.netloc) + except Exception: + return False