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 推流使用同一物理设备,预览走浏览器采集)。
+
+
+
-
-
-
-
📷 物理摄像头直播
-
摄像头 → YouTube
-
- 使用本机摄像头与麦克风采集画面,经 FFmpeg 推送到 YouTube 直播间。与抖音转播线路独立,请勿同时开启冲突进程。
-
-
-
-
-
- {ready ? '可启动' : !keyConfigured ? '缺少密钥' : !cameraConfigured ? '未选摄像头' : '未就绪'}
-
-
- {statusLine.line}
-
-
+
+
+
+ 画面预览
+
+ {config.video_device.trim() || '未选择设备'}
+
+
+
+
+