diff --git a/config/ffmpeg_proxy.ini b/config/ffmpeg_proxy.ini
new file mode 100644
index 0000000..afcdf7d
--- /dev/null
+++ b/config/ffmpeg_proxy.ini
@@ -0,0 +1,6 @@
+[proxy]
+# 仅作用于本软件拉起的录制/推流进程,不会修改 Windows「系统代理」
+# 与本机 Clash Verge 等「混合端口」一致即可(示例端口 7890)
+enabled = no
+http = http://127.0.0.1:7890
+socks = socks5://127.0.0.1:7890
diff --git a/douyin_youtube_ffplay.py b/douyin_youtube_ffplay.py
index 6a655df..c2c48db 100644
--- a/douyin_youtube_ffplay.py
+++ b/douyin_youtube_ffplay.py
@@ -11,6 +11,8 @@ import re
import os
import configparser
+from web2_proxy_config import merge_child_env
+
class StreamEnded(Exception):
"""主播真实下播的专用异常"""
@@ -279,6 +281,7 @@ def start_douyin_youtube_ffplay(
)
if _ffmpeg_cwd:
_popen_kw["cwd"] = _ffmpeg_cwd
+ _popen_kw["env"] = merge_child_env()
proc = subprocess.Popen(ffmpeg_command, **_popen_kw)
proc.last_out_ts = time.time()
diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts
index 30db9f0..c48d679 100644
--- a/electron/src/main/index.ts
+++ b/electron/src/main/index.ts
@@ -149,12 +149,12 @@ function resolvePreload(): string {
function createWindow(): void {
mainWindow = new BrowserWindow({
- width: 920,
- height: 860,
- minWidth: 640,
+ width: 1020,
+ height: 720,
+ minWidth: 720,
minHeight: 560,
show: false,
- backgroundColor: '#f0f0f0',
+ backgroundColor: '#14161c',
title: '直播录制助手',
autoHideMenuBar: true,
webPreferences: {
diff --git a/electron/src/renderer/index.html b/electron/src/renderer/index.html
index f081a90..f166cf5 100644
--- a/electron/src/renderer/index.html
+++ b/electron/src/renderer/index.html
@@ -3,7 +3,7 @@
-
+
直播录制助手
diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css
index 763e3fe..c42a354 100644
--- a/electron/src/renderer/src/App.css
+++ b/electron/src/renderer/src/App.css
@@ -1,47 +1,53 @@
-/* 直播录制助手 — 浅色客户端 + 精致层次(离线可用,无外链字体) */
+/* 直播录制助手 — 借鉴 Clash Verge 类客户端:深色侧栏 + 主工作区(离线无外链字体) */
:root {
--font-ui: "Segoe UI", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
--font-mono: "Cascadia Mono", "Consolas", ui-monospace, monospace;
- --bg-app-0: #e8edf4;
- --bg-app-1: #f0f4f9;
- --bg-chrome: rgba(255, 255, 255, 0.92);
- --bg-card: #ffffff;
- --bg-card-muted: #f8fafc;
- --bg-input: #fcfcfd;
- --bg-log-0: #2b2d31;
- --bg-log-1: #1a1b1e;
- --border-subtle: rgba(15, 23, 42, 0.08);
- --border-card: rgba(15, 23, 42, 0.06);
- --border-strong: rgba(15, 23, 42, 0.14);
- --text: #1a1d21;
- --text-muted: #5f6b7a;
- --text-on-dark: #e4e6eb;
+ --bg-app-0: #12141a;
+ --bg-app-1: #16181f;
+ --bg-sidebar: #1b1f28;
+ --bg-sidebar-hover: #232830;
+ --bg-workspace: #14161c;
+ --bg-chrome: #1a1d24;
+ --bg-card: #22262e;
+ --bg-card-elevated: #282c36;
+ --bg-card-muted: #2a2f3a;
+ --bg-input: #1e222a;
+ --bg-log-0: #1e2128;
+ --bg-log-1: #14161c;
+ --border-subtle: rgba(255, 255, 255, 0.06);
+ --border-card: rgba(255, 255, 255, 0.08);
+ --border-strong: rgba(255, 255, 255, 0.12);
+ --text: #e8eaed;
+ --text-muted: #8b929e;
+ --text-on-dark: #c8cdd4;
- --accent: #0067c0;
- --accent-2: #0f6cbd;
- --accent-soft: rgba(0, 103, 192, 0.1);
- --accent-glow: rgba(0, 103, 192, 0.22);
- --accent-hover: #005a9e;
- --success: #0e7a0d;
- --success-soft: rgba(14, 122, 13, 0.12);
- --danger: #c42b1c;
- --danger-soft: rgba(196, 43, 28, 0.1);
- --warn: #c35a12;
- --warn-soft: rgba(195, 90, 18, 0.12);
+ --accent: #5b9dff;
+ --accent-2: #7ab3ff;
+ --accent-dim: rgba(91, 157, 255, 0.14);
+ --accent-soft: rgba(91, 157, 255, 0.18);
+ --accent-glow: rgba(91, 157, 255, 0.35);
+ --accent-hover: #6aa8ff;
+ --success: #3dd68c;
+ --success-soft: rgba(61, 214, 140, 0.12);
+ --danger: #f56565;
+ --danger-soft: rgba(245, 101, 101, 0.12);
+ --warn: #f6ad55;
+ --warn-soft: rgba(246, 173, 85, 0.12);
- --radius: 6px;
+ --radius: 8px;
--radius-md: 10px;
- --radius-lg: 14px;
+ --radius-lg: 12px;
--radius-pill: 999px;
- --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
- --shadow-md: 0 4px 14px rgba(15, 23, 42, 0.07), 0 1px 2px rgba(15, 23, 42, 0.04);
- --shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.1), 0 2px 8px rgba(15, 23, 42, 0.05);
- --shadow-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.75);
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
+ --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.35);
+ --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.45);
+ --shadow-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.06);
--ease: cubic-bezier(0.2, 0, 0, 1);
+ --sidebar-width: 220px;
}
*,
@@ -56,7 +62,7 @@ html {
}
::selection {
- background: var(--accent-soft);
+ background: var(--accent-dim);
color: var(--text);
}
@@ -67,73 +73,200 @@ body {
font-size: 14px;
line-height: 1.55;
color: var(--text);
- background-color: var(--bg-app-1);
- background-image:
- radial-gradient(ellipse 120% 80% at 50% -20%, rgba(0, 103, 192, 0.09), transparent 55%),
- linear-gradient(165deg, var(--bg-app-0) 0%, var(--bg-app-1) 45%, #eef2f7 100%);
+ background-color: var(--bg-workspace);
+ background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(91, 157, 255, 0.08), transparent 50%);
}
body.ready {
opacity: 1;
}
-/* 根布局 */
+/* 根布局(旧顶栏已弃用,改侧栏) */
.app-frame {
min-height: 100vh;
display: flex;
flex-direction: column;
}
-.app-chrome {
- position: relative;
+.app-frame--verge {
+ flex-direction: row;
+ min-height: 100vh;
+}
+
+/* —— Clash Verge 风格侧栏 —— */
+.app-sidebar {
+ width: var(--sidebar-width);
flex-shrink: 0;
- padding: 20px 24px 18px;
- background: var(--bg-chrome);
- backdrop-filter: blur(16px);
- -webkit-backdrop-filter: blur(16px);
+ display: flex;
+ flex-direction: column;
+ background: linear-gradient(180deg, var(--bg-sidebar) 0%, #181c24 100%);
+ border-right: 1px solid var(--border-subtle);
+ box-shadow: 4px 0 24px rgba(0, 0, 0, 0.25);
+}
+
+.app-sidebar__brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 20px 16px 18px;
border-bottom: 1px solid var(--border-subtle);
- box-shadow: var(--shadow-sm), 0 8px 32px rgba(15, 23, 42, 0.04);
}
-.app-chrome::before {
- content: "";
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- height: 3px;
- background: linear-gradient(90deg, #0f6cbd 0%, #4a9fe8 48%, #7c5cff 100%);
- opacity: 0.95;
+.app-sidebar__logo {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ background: linear-gradient(145deg, #3d7eff 0%, #5b9dff 40%, #7c5cff 100%);
+ box-shadow: 0 4px 16px rgba(91, 157, 255, 0.35);
+ flex-shrink: 0;
}
-.app-chrome__title {
- margin: 0;
- font-size: 22px;
- font-weight: 650;
- letter-spacing: -0.03em;
- line-height: 1.25;
+.app-sidebar__app-name {
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
color: var(--text);
- text-shadow: 0 1px 0 rgba(255, 255, 255, 0.8);
+ line-height: 1.25;
}
-.app-chrome__subtitle {
- margin: 8px 0 0;
+.app-sidebar__app-tag {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-top: 2px;
+}
+
+.app-sidebar__nav {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 12px 10px;
+ flex: 1;
+}
+
+.app-sidebar__link {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 11px 14px;
+ border: none;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--text-muted);
+ font-family: var(--font-ui);
+ font-size: 14px;
+ font-weight: 600;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ background 0.15s var(--ease),
+ color 0.15s var(--ease);
+}
+
+.app-sidebar__link:hover {
+ background: var(--bg-sidebar-hover);
+ color: var(--text);
+}
+
+.app-sidebar__link.is-active {
+ background: rgba(91, 157, 255, 0.12);
+ color: var(--accent);
+ box-shadow: inset 3px 0 0 var(--accent);
+}
+
+.app-sidebar__link-label {
+ position: relative;
+ z-index: 1;
+}
+
+.app-sidebar__link-glow {
+ position: absolute;
+ inset: 0;
+ border-radius: var(--radius-md);
+ background: radial-gradient(circle at 0% 50%, rgba(91, 157, 255, 0.15), transparent 65%);
+ pointer-events: none;
+}
+
+/* 主工作区 */
+.app-workspace {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-workspace);
+}
+
+.app-workspace__header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 18px 24px 14px;
+ border-bottom: 1px solid var(--border-subtle);
+ background: rgba(20, 22, 28, 0.85);
+ backdrop-filter: blur(12px);
+}
+
+.app-workspace__title {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: -0.03em;
+ color: var(--text);
+}
+
+.app-workspace__subtitle {
+ margin: 4px 0 0;
font-size: 13px;
color: var(--text-muted);
- letter-spacing: 0.01em;
+}
+
+.app-workspace__scroll {
+ flex: 1;
+ overflow: auto;
+ padding: 16px 24px 28px;
+}
+
+.app-workspace__inner {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.app-workspace__inner--center {
+ align-items: center;
+ justify-content: center;
+ padding: 32px 24px;
+}
+
+.app-panel {
+ max-width: 920px;
+ margin: 0 auto;
+}
+
+.app-panel > .card {
+ margin-bottom: 0;
+}
+
+.app-body--record {
+ max-width: 920px;
+ margin: 0 auto;
+ padding: 0;
}
.app-body {
flex: 1;
- padding: 20px 24px 32px;
- max-width: 920px;
+ padding: 0;
+ max-width: none;
}
.app-body--padded {
display: flex;
align-items: flex-start;
justify-content: center;
- padding-top: 40px;
+ padding: 40px 24px;
}
.app-toolbar {
@@ -172,10 +305,10 @@ body.ready {
max-width: 100%;
cursor: pointer;
box-shadow: var(--shadow-inset-top), var(--shadow-sm);
- background-color: #f8fafc;
+ background-color: var(--bg-input);
background-image:
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235f6b7a' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"),
- linear-gradient(180deg, #ffffff 0%, #f4f6f9 100%);
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238b929e' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"),
+ linear-gradient(180deg, #2a2f38 0%, #22262e 100%);
background-repeat: no-repeat, no-repeat;
background-position: right 12px center, 0 0;
background-size: 16px 16px, 100% 100%;
@@ -186,7 +319,7 @@ body.ready {
}
.select-process:hover:not(:disabled) {
- border-color: rgba(0, 103, 192, 0.45);
+ border-color: rgba(91, 157, 255, 0.45);
box-shadow: var(--shadow-inset-top), 0 0 0 1px var(--accent-glow);
}
@@ -211,9 +344,9 @@ body.ready {
padding: 8px 14px 8px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--border-card);
- background: linear-gradient(180deg, #ffffff 0%, #f4f6f9 100%);
+ background: var(--bg-card);
color: var(--text-muted);
- box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.9);
+ box-shadow: var(--shadow-sm), var(--shadow-inset-top);
}
.status-pill__dot {
@@ -221,7 +354,7 @@ body.ready {
height: 9px;
border-radius: 50%;
background: currentColor;
- box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
+ box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
}
.status-pill[data-variant='loading'] .status-pill__dot {
@@ -256,34 +389,34 @@ body.ready {
}
.status-pill[data-variant='online'] {
- border-color: rgba(14, 122, 13, 0.28);
- background: linear-gradient(180deg, #f4fbf4 0%, #e8f5e8 100%);
+ border-color: rgba(61, 214, 140, 0.35);
+ background: rgba(61, 214, 140, 0.1);
color: var(--success);
}
.status-pill[data-variant='stopped'],
.status-pill[data-variant='offline'] {
- border-color: rgba(196, 43, 28, 0.22);
- background: linear-gradient(180deg, #fff8f7 0%, #fcefea 100%);
+ border-color: rgba(245, 101, 101, 0.35);
+ background: rgba(245, 101, 101, 0.1);
color: var(--danger);
}
.status-pill[data-variant='warn'] {
- border-color: rgba(195, 90, 18, 0.28);
- background: linear-gradient(180deg, #fffaf5 0%, #fff0e0 100%);
+ border-color: rgba(246, 173, 85, 0.35);
+ background: rgba(246, 173, 85, 0.1);
color: var(--warn);
}
.status-pill[data-variant='error'] {
- border-color: rgba(196, 43, 28, 0.28);
- background: linear-gradient(180deg, #fff8f7 0%, #fcefea 100%);
+ border-color: rgba(245, 101, 101, 0.35);
+ background: rgba(245, 101, 101, 0.12);
color: var(--danger);
}
.status-pill[data-variant='unknown'],
.status-pill[data-variant='empty'] {
- border-color: rgba(0, 103, 192, 0.2);
- background: linear-gradient(180deg, #f5f9ff 0%, #e8f2fc 100%);
+ border-color: rgba(91, 157, 255, 0.25);
+ background: rgba(91, 157, 255, 0.1);
color: var(--accent-2);
}
@@ -295,7 +428,7 @@ body.ready {
gap: 8px 14px;
margin-bottom: 18px;
padding: 12px 16px;
- background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(248, 250, 252, 0.98) 100%);
+ background: var(--bg-card-elevated);
border: 1px solid var(--border-card);
border-radius: var(--radius-md);
font-size: 13px;
@@ -332,10 +465,10 @@ body.ready {
border-radius: var(--radius-md);
background: linear-gradient(
110deg,
- #e8ecf2 0%,
- #f4f6fa 40%,
- #e8ecf2 80%,
- #e8ecf2 100%
+ #2a2f38 0%,
+ #363c48 40%,
+ #2a2f38 80%,
+ #2a2f38 100%
);
background-size: 200% 100%;
animation: shimmer 1.15s ease-in-out infinite;
@@ -384,10 +517,8 @@ body.ready {
height: 56px;
margin: 0 auto 16px;
border-radius: 16px;
- background: linear-gradient(145deg, #e8eef6 0%, #d4e4f7 45%, #c8daf0 100%);
- box-shadow:
- var(--shadow-inset-top),
- 0 8px 24px rgba(0, 103, 192, 0.12);
+ background: linear-gradient(145deg, #2a3444 0%, #1e2632 100%);
+ box-shadow: var(--shadow-inset-top), 0 8px 24px rgba(0, 0, 0, 0.35);
position: relative;
}
@@ -396,7 +527,7 @@ body.ready {
position: absolute;
inset: 14px;
border-radius: 8px;
- border: 2px dashed rgba(0, 103, 192, 0.25);
+ border: 2px dashed rgba(91, 157, 255, 0.28);
opacity: 0.85;
}
@@ -415,8 +546,8 @@ body.ready {
align-items: flex-start;
padding: 20px 22px;
border-radius: var(--radius-lg);
- background: linear-gradient(180deg, #ffffff 0%, #fffbfb 100%);
- border: 1px solid rgba(196, 43, 28, 0.22);
+ background: var(--bg-card);
+ border: 1px solid rgba(245, 101, 101, 0.35);
box-shadow: var(--shadow-md);
}
@@ -500,9 +631,9 @@ body.ready {
}
.card--accent {
- border-color: rgba(0, 103, 192, 0.18);
- background: linear-gradient(180deg, #fbfdff 0%, #f3f8fd 100%);
- box-shadow: var(--shadow-md), 0 0 0 1px rgba(0, 103, 192, 0.04);
+ border-color: rgba(91, 157, 255, 0.25);
+ background: linear-gradient(180deg, rgba(91, 157, 255, 0.08) 0%, var(--bg-card) 100%);
+ box-shadow: var(--shadow-md), 0 0 0 1px rgba(91, 157, 255, 0.06);
}
.card--log .card__desc {
@@ -522,7 +653,7 @@ body.ready {
border-radius: var(--radius-md);
resize: vertical;
min-height: 108px;
- box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.04);
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
transition:
border-color 0.18s var(--ease),
box-shadow 0.2s var(--ease);
@@ -532,7 +663,7 @@ body.ready {
outline: none;
border-color: var(--accent);
box-shadow:
- inset 0 1px 2px rgba(15, 23, 42, 0.04),
+ inset 0 1px 2px rgba(0, 0, 0, 0.25),
0 0 0 3px var(--accent-soft);
}
@@ -559,31 +690,31 @@ body.ready {
}
.btn-secondary {
- background: linear-gradient(180deg, #ffffff 0%, #f3f5f8 100%);
+ background: linear-gradient(180deg, #2f3540 0%, #282c34 100%);
color: var(--text);
border-color: var(--border-strong);
box-shadow: var(--shadow-inset-top), var(--shadow-sm);
}
.btn-secondary:hover {
- background: linear-gradient(180deg, #fafbfc 0%, #eceff4 100%);
- border-color: rgba(15, 23, 42, 0.22);
+ background: linear-gradient(180deg, #3a404c 0%, #323842 100%);
+ border-color: rgba(255, 255, 255, 0.18);
}
.btn-primary {
- background: linear-gradient(180deg, #0f6cbd 0%, #0067c0 55%, #005a9e 100%);
+ background: linear-gradient(180deg, #4a8eef 0%, #3d7eff 45%, #2f6fd6 100%);
color: #fff;
- border-color: rgba(0, 0, 0, 0.08);
+ border-color: rgba(0, 0, 0, 0.2);
box-shadow:
var(--shadow-inset-top),
- 0 4px 12px rgba(0, 103, 192, 0.28);
+ 0 4px 16px rgba(91, 157, 255, 0.35);
}
.btn-primary:hover {
- background: linear-gradient(180deg, #1a7ad4 0%, #0f6cbd 55%, #0067c0 100%);
+ background: linear-gradient(180deg, #5b9dff 0%, #4a8eef 55%, #3d7eff 100%);
box-shadow:
var(--shadow-inset-top),
- 0 6px 18px rgba(0, 103, 192, 0.35);
+ 0 6px 22px rgba(91, 157, 255, 0.45);
}
.btn-primary:active,
@@ -616,11 +747,11 @@ body.ready {
font-family: var(--font-mono);
font-size: 13px;
color: var(--accent-2);
- background: linear-gradient(135deg, #f5f9ff 0%, #e8f2fc 100%);
- border: 1px dashed rgba(0, 103, 192, 0.35);
+ background: rgba(91, 157, 255, 0.08);
+ border: 1px dashed rgba(91, 157, 255, 0.35);
border-radius: var(--radius-md);
word-break: break-all;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
+ box-shadow: var(--shadow-inset-top);
}
/* 录制按钮区 */
@@ -695,7 +826,7 @@ body.ready {
}
.ctrl-btn--status {
- background: linear-gradient(180deg, #ffffff 0%, #f1f4f8 100%);
+ background: linear-gradient(180deg, #323842 0%, #2a2f38 100%);
color: var(--text);
border-color: var(--border-strong);
box-shadow: var(--shadow-inset-top), var(--shadow-sm);
@@ -707,8 +838,8 @@ body.ready {
.ctrl-btn--status:hover {
filter: none;
- border-color: rgba(0, 103, 192, 0.45);
- background: linear-gradient(180deg, #ffffff 0%, #e8f2fc 100%);
+ border-color: rgba(91, 157, 255, 0.45);
+ background: linear-gradient(180deg, #3a404c 0%, #323842 100%);
box-shadow: var(--shadow-inset-top), 0 0 0 1px var(--accent-glow);
}
@@ -735,8 +866,8 @@ body.ready {
color: var(--accent-2);
padding: 5px 10px;
border-radius: var(--radius-pill);
- background: linear-gradient(180deg, #e8f2fc 0%, #dceaf8 100%);
- border: 1px solid rgba(0, 103, 192, 0.18);
+ background: rgba(91, 157, 255, 0.1);
+ border: 1px solid rgba(91, 157, 255, 0.22);
box-shadow: var(--shadow-inset-top);
}
@@ -768,7 +899,7 @@ body.ready {
.log-panel--muted {
text-align: left;
color: var(--text-muted);
- background: linear-gradient(180deg, #fafbfc 0%, #f3f5f8 100%);
+ background: var(--bg-card-elevated);
border: 1px solid var(--border-card);
min-height: auto;
height: auto;
@@ -794,3 +925,425 @@ body.ready {
.log-panel::-webkit-scrollbar-thumb:hover {
background: #6b6f7a;
}
+
+/* —— 网络仪表盘 —— */
+.card--network {
+ margin-bottom: 4px;
+ border-color: rgba(124, 92, 255, 0.28);
+ background: linear-gradient(165deg, rgba(124, 92, 255, 0.08) 0%, var(--bg-card) 55%);
+ box-shadow: var(--shadow-md), 0 0 0 1px rgba(124, 92, 255, 0.08);
+}
+
+.net-dash__header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.net-dash__lead {
+ margin-top: 4px !important;
+ max-width: 52rem;
+}
+
+.net-dash__refresh {
+ flex-shrink: 0;
+}
+
+.net-dash__error {
+ margin: 0 0 12px;
+ padding: 10px 12px;
+ font-size: 13px;
+ color: var(--danger);
+ background: var(--danger-soft);
+ border: 1px solid rgba(245, 101, 101, 0.35);
+ border-radius: var(--radius-md);
+}
+
+.net-dash__loading {
+ font-size: 13px;
+ color: var(--text-muted);
+ padding: 8px 0 4px;
+}
+
+.net-site-row {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+@media (max-width: 720px) {
+ .net-site-row {
+ grid-template-columns: 1fr;
+ }
+}
+
+.net-site {
+ padding: 12px 14px;
+ border-radius: var(--radius-md);
+ background: var(--bg-card-elevated);
+ border: 1px solid var(--border-card);
+ box-shadow: var(--shadow-sm);
+}
+
+.net-site__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.net-site__title {
+ font-size: 13px;
+ font-weight: 650;
+ color: var(--text);
+}
+
+.net-badge {
+ font-size: 11px;
+ font-weight: 700;
+ padding: 3px 8px;
+ border-radius: var(--radius-pill);
+ letter-spacing: 0.02em;
+}
+
+.net-badge--ok {
+ color: var(--success);
+ background: var(--success-soft);
+ border: 1px solid rgba(61, 214, 140, 0.35);
+}
+
+.net-badge--bad {
+ color: var(--danger);
+ background: var(--danger-soft);
+ border: 1px solid rgba(245, 101, 101, 0.35);
+}
+
+.net-site__grid {
+ display: grid;
+ grid-template-columns: 72px 1fr;
+ gap: 6px 10px;
+ font-size: 12px;
+ align-items: baseline;
+}
+
+.net-k {
+ color: var(--text-muted);
+ font-weight: 600;
+}
+
+.net-v {
+ color: var(--text);
+ word-break: break-word;
+}
+
+.net-v--mono {
+ font-family: var(--font-mono);
+ font-size: 11.5px;
+}
+
+.net-site__err {
+ margin: 10px 0 0;
+ font-size: 11px;
+ line-height: 1.45;
+ color: var(--warn);
+}
+
+.net-routes {
+ margin-bottom: 8px;
+ padding: 12px 14px;
+ border-radius: var(--radius-md);
+ background: rgba(91, 157, 255, 0.06);
+ border: 1px solid rgba(91, 157, 255, 0.15);
+}
+
+.net-routes__label {
+ display: block;
+ font-size: 12px;
+ font-weight: 650;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+}
+
+.net-routes__pill {
+ display: inline-block;
+ font-size: 13px;
+ font-weight: 700;
+ padding: 6px 12px;
+ border-radius: var(--radius-pill);
+ margin-bottom: 8px;
+}
+
+.net-routes__pill--yes {
+ color: var(--success);
+ background: var(--success-soft);
+ border: 1px solid rgba(61, 214, 140, 0.35);
+}
+
+.net-routes__pill--no {
+ color: var(--warn);
+ background: var(--warn-soft);
+ border: 1px solid rgba(195, 90, 18, 0.28);
+}
+
+.net-routes__pill--unk {
+ color: var(--text-muted);
+ background: var(--bg-card-muted);
+ border: 1px solid var(--border-card);
+}
+
+.net-routes__note {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--text-muted);
+}
+
+.net-speed__title {
+ margin: 0 0 10px;
+ font-size: 13px;
+ font-weight: 650;
+ color: var(--text);
+}
+
+.net-speed__grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+
+@media (max-width: 520px) {
+ .net-speed__grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.net-speed__col {
+ padding: 12px 14px;
+ border-radius: var(--radius-md);
+ background: var(--bg-card-elevated);
+ border: 1px solid var(--border-subtle);
+}
+
+.net-speed__head {
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--accent-2);
+ margin-bottom: 10px;
+}
+
+.net-metric {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 10px;
+ padding: 6px 0;
+ border-bottom: 1px dashed var(--border-subtle);
+ font-size: 13px;
+}
+
+.net-metric:last-of-type {
+ border-bottom: none;
+}
+
+.net-metric__k {
+ color: var(--text-muted);
+ font-weight: 600;
+}
+
+.net-metric__v {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ color: var(--text);
+}
+
+.net-speed__hint {
+ margin: 8px 0 0;
+ font-size: 10px;
+ line-height: 1.4;
+ color: var(--warn);
+ word-break: break-word;
+}
+
+.net-speed__foot {
+ margin: 12px 0 0;
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--text-muted);
+}
+
+/* —— 录制代理 + 系统指标 —— */
+.card--proxy {
+ border-color: rgba(61, 214, 140, 0.22);
+ background: linear-gradient(165deg, rgba(61, 214, 140, 0.06) 0%, var(--bg-card) 100%);
+}
+
+.proxy-form {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-width: 36rem;
+}
+
+.proxy-check {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ user-select: none;
+}
+
+.proxy-input {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ padding: 9px 12px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-md);
+ background: var(--bg-input);
+ color: var(--text);
+}
+
+.proxy-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-soft);
+}
+
+.proxy-form__msg {
+ margin: 4px 0 0;
+ font-size: 13px;
+ color: var(--success);
+}
+
+.card--metrics {
+ border-color: rgba(124, 92, 255, 0.22);
+ background: linear-gradient(165deg, rgba(124, 92, 255, 0.07) 0%, var(--bg-card) 100%);
+}
+
+.metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+@media (max-width: 720px) {
+ .metrics-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.metrics-block {
+ padding: 12px 14px;
+ border-radius: var(--radius-md);
+ background: var(--bg-card-elevated);
+ border: 1px solid var(--border-subtle);
+}
+
+.metrics-block__title {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+}
+
+.metrics-big {
+ font-size: 22px;
+ font-weight: 750;
+ letter-spacing: -0.02em;
+ color: var(--text);
+}
+
+.metrics-sub {
+ margin-top: 4px;
+ font-size: 11px;
+ color: var(--text-muted);
+ line-height: 1.4;
+}
+
+.metrics-rate {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 650;
+ color: var(--accent-2);
+}
+
+.metrics-swap {
+ font-size: 12px;
+ color: var(--text-muted);
+ margin-bottom: 10px;
+}
+
+.metrics-disks {
+ margin-top: 8px;
+}
+
+.metrics-disks__title {
+ font-size: 12px;
+ font-weight: 650;
+ margin-bottom: 6px;
+ color: var(--text);
+}
+
+.metrics-disk-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.metrics-disk-list li {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 12px;
+ padding: 6px 0;
+ border-bottom: 1px dashed var(--border-subtle);
+}
+
+.metrics-disk-list__mp {
+ font-family: var(--font-mono);
+ color: var(--text);
+ min-width: 5rem;
+}
+
+.metrics-ff {
+ margin-top: 12px;
+}
+
+.metrics-ff__title {
+ font-size: 12px;
+ font-weight: 650;
+ margin-bottom: 6px;
+}
+
+.metrics-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.metrics-table th,
+.metrics-table td {
+ text-align: left;
+ padding: 6px 8px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.metrics-table th {
+ color: var(--text-muted);
+ font-weight: 600;
+}
diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx
index a36cf55..08143eb 100644
--- a/electron/src/renderer/src/App.tsx
+++ b/electron/src/renderer/src/App.tsx
@@ -1,10 +1,22 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { apiUrl } from './api'
+import NetworkDashboard from './NetworkDashboard'
+import ProxySettings from './ProxySettings'
+import SystemMetrics from './SystemMetrics'
import { UI_DROPDOWN_YOUTUBE_ONLY } from './constants'
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils'
type Entry = { script: string; label: string; pm2: string }
+type NavId = 'record' | 'network' | 'proxy' | 'system'
+
+const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [
+ { id: 'record', label: '录制', hint: '任务与推流控制' },
+ { id: 'network', label: '网络', hint: '连通与测速' },
+ { id: 'proxy', label: '代理', hint: '仅录制进程' },
+ { id: 'system', label: '系统', hint: '负载与网速' },
+]
+
type StatusVariant =
| 'loading'
| 'online'
@@ -49,6 +61,8 @@ export default function App() {
const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false)
const [saveUrlLoading, setSaveUrlLoading] = useState(false)
+ const [nav, setNav] = useState('record')
+
const logRef = useRef(null)
const scrollLog = useCallback(() => {
@@ -249,18 +263,26 @@ export default function App() {
if (bootError) {
return (
-
-
-
-
-
- !
-
+
+
+
@@ -268,14 +290,73 @@ export default function App() {
)
}
- return (
-
-
- 直播录制助手
- 打开即可用,无需单独安装运行环境
-
+ const navMeta = NAV_ITEMS.find((n) => n.id === nav)
-
+ return (
+
+
+
+
+
+
+
{navMeta?.label ?? ''}
+
{navMeta?.hint}
+
+ {nav === 'record' && (
+
+
+ {statusMeta.line}
+
+ )}
+
+
+
+ {nav === 'network' && (
+
+
+
+ )}
+ {nav === 'proxy' && (
+
+ )}
+ {nav === 'system' && (
+
+
+
+ )}
+
+ {nav === 'record' && (
+
-
-
- {statusMeta.line}
-
{initialLoad && (
@@ -474,6 +545,9 @@ export default function App() {
)}
+
+ )}
+
)
diff --git a/electron/src/renderer/src/NetworkDashboard.tsx b/electron/src/renderer/src/NetworkDashboard.tsx
new file mode 100644
index 0000000..9f63875
--- /dev/null
+++ b/electron/src/renderer/src/NetworkDashboard.tsx
@@ -0,0 +1,204 @@
+import { useCallback, useEffect, useState } from 'react'
+import { apiUrl } from './api'
+
+type SiteInfo = {
+ host: string
+ reachable: boolean
+ latency_ms: number
+ error: string | null
+ resolved_ip: string | null
+ country: string | null
+ country_code: string | null
+ geo_error: string | null
+}
+
+type SpeedBlock = {
+ label: string
+ download_mbps: number | null
+ download_error: string | null
+ download_bytes_sampled: number
+ upload_mbps: number | null
+ upload_error: string | null
+ upload_target: string | null
+}
+
+type NetworkPayload = {
+ google: SiteInfo
+ douyin: SiteInfo
+ routes: { different: boolean | null; note: string }
+ speed: { overseas: SpeedBlock; domestic: SpeedBlock }
+ meta?: { at: number; download_cap_bytes: number; upload_bytes: number }
+ error?: string
+}
+
+function fmtMbps(v: number | null | undefined): string {
+ if (v == null || Number.isNaN(v)) return '—'
+ return `${v.toFixed(2)} Mbps`
+}
+
+function SiteRow({ title, site }: { title: string; site: SiteInfo }) {
+ const ok = site.reachable
+ return (
+
+
+ {title}
+
+ {ok ? '可访问' : '不可访问'}
+
+
+
+ 延迟
+ {site.latency_ms != null ? `${site.latency_ms.toFixed(0)} ms` : '—'}
+ 解析 IP
+ {site.resolved_ip ?? '—'}
+ 地区(参考)
+
+ {site.country ?? '—'}
+ {site.country_code ? ` (${site.country_code})` : ''}
+
+
+ {(site.error || site.geo_error) && (
+
+ {[site.error, site.geo_error].filter(Boolean).join(' · ')}
+
+ )}
+
+ )
+}
+
+export default function NetworkDashboard() {
+ const [data, setData] = useState
(null)
+ const [loading, setLoading] = useState(true)
+ const [err, setErr] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setErr(null)
+ try {
+ const res = await fetch(apiUrl('/network_status'))
+ const json = (await res.json()) as NetworkPayload & { error?: string }
+ if (!res.ok) {
+ setErr(json.error || `请求失败 ${res.status}`)
+ setData(null)
+ return
+ }
+ setData(json)
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : String(e))
+ setData(null)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ useEffect(() => {
+ const id = window.setInterval(() => void load(), 45000)
+ return () => clearInterval(id)
+ }, [load])
+
+ return (
+
+
+
+
+ 网络仪表盘
+
+
+ 检测 Google(偏海外)与抖音(偏国内)是否通畅、解析出口是否像两条线,并估算上下行(采样约 0.6MB
+ 下行 / 256KB 上行)。
+
+
+
+
+
+ {err && (
+
+ {err}
+
+ )}
+
+ {loading && !data && !err && 正在检测网络…
}
+
+ {data && (
+ <>
+
+
+
+
+
+
+
线路是否像「两条线」
+
+ {data.routes.different === true
+ ? '是(解析国家不同)'
+ : data.routes.different === false
+ ? '否或不确定'
+ : '未知'}
+
+
{data.routes.note}
+
+
+
+
网速(估算)
+
+
+
海外
+
+ 下行
+ {fmtMbps(data.speed.overseas.download_mbps)}
+
+
+ 上行
+ {fmtMbps(data.speed.overseas.upload_mbps)}
+
+ {(data.speed.overseas.download_error || data.speed.overseas.upload_error) && (
+
+ {[data.speed.overseas.download_error, data.speed.overseas.upload_error]
+ .filter(Boolean)
+ .join(' · ')}
+
+ )}
+
+
+
国内
+
+ 下行
+ {fmtMbps(data.speed.domestic.download_mbps)}
+
+
+ 上行
+ {fmtMbps(data.speed.domestic.upload_mbps)}
+
+ {(data.speed.domestic.download_error || data.speed.domestic.upload_error) && (
+
+ {[data.speed.domestic.download_error, data.speed.domestic.upload_error]
+ .filter(Boolean)
+ .join(' · ')}
+
+ )}
+
+
+
+ 上行若失败多为网站不接受测试流量,不代表您无法上传直播;海外项走 Google / httpbin,国内项走抖音首页流与门户
+ POST。
+
+
+ >
+ )}
+
+ )
+}
diff --git a/electron/src/renderer/src/ProxySettings.tsx b/electron/src/renderer/src/ProxySettings.tsx
new file mode 100644
index 0000000..ebf75e7
--- /dev/null
+++ b/electron/src/renderer/src/ProxySettings.tsx
@@ -0,0 +1,112 @@
+import { useCallback, useEffect, useState } from 'react'
+import { apiUrl } from './api'
+
+type ProxyCfg = { enabled: boolean; http: string; socks: string }
+
+export default function ProxySettings() {
+ const [cfg, setCfg] = useState({ enabled: false, http: '', socks: '' })
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [msg, setMsg] = useState('')
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setMsg('')
+ try {
+ const res = await fetch(apiUrl('/proxy_config'))
+ const data = (await res.json()) as ProxyCfg
+ if (!res.ok) throw new Error('读取失败')
+ setCfg({
+ enabled: !!data.enabled,
+ http: data.http || '',
+ socks: data.socks || '',
+ })
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : String(e))
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ const save = async () => {
+ setSaving(true)
+ setMsg('')
+ try {
+ const res = await fetch(apiUrl('/proxy_config'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(cfg),
+ })
+ if (!res.ok) throw new Error('保存失败')
+ setMsg('已保存。重启录制任务后生效。')
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : String(e))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+ 录制专用代理(不改系统网络)
+
+
+ 仅对本软件拉起的 FFmpeg / 录制进程生效,不会修改 Windows
+ 系统代理。若你使用 Clash Verge 等,请在本机开启「混合端口」后,把地址填成与之一致(常见为 7890)。
+ YouTube RTMP 推流若仍失败,可能需要代理软件开启 TUN 或自行调整规则。
+
+ {loading ? (
+ 正在读取配置…
+ ) : (
+
+
+
+
setCfg((c) => ({ ...c, http: e.target.value }))}
+ autoComplete="off"
+ />
+
+
setCfg((c) => ({ ...c, socks: e.target.value }))}
+ autoComplete="off"
+ />
+
+
+
+
+ {msg &&
{msg}
}
+
+ )}
+
+ )
+}
diff --git a/electron/src/renderer/src/SystemMetrics.tsx b/electron/src/renderer/src/SystemMetrics.tsx
new file mode 100644
index 0000000..e7c1dd4
--- /dev/null
+++ b/electron/src/renderer/src/SystemMetrics.tsx
@@ -0,0 +1,144 @@
+import { useCallback, useEffect, useState } from 'react'
+import { apiUrl } from './api'
+
+type Metrics = {
+ ts?: number
+ cpu_percent?: number | null
+ cpu_count_logical?: number
+ memory?: { percent?: number; used?: number; total?: number; available?: number }
+ swap?: { percent?: number; used?: number; total?: number }
+ disks?: Array<{
+ mountpoint?: string
+ percent?: number
+ free?: number
+ total?: number
+ }>
+ network?: { up_mbps?: number | null; down_mbps?: number | null }
+ ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }>
+ error?: string
+}
+
+function fmtBytes(n: number | undefined): string {
+ if (n == null || Number.isNaN(n)) return '—'
+ if (n < 1024) return `${n} B`
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
+ return `${(n / 1024 ** 3).toFixed(2)} GB`
+}
+
+export default function SystemMetrics() {
+ const [data, setData] = useState(null)
+ const [err, setErr] = useState(null)
+
+ const load = useCallback(async () => {
+ try {
+ const res = await fetch(apiUrl('/system_metrics'))
+ const json = (await res.json()) as Metrics
+ if (!res.ok) {
+ setErr(json.error || `HTTP ${res.status}`)
+ return
+ }
+ setErr(null)
+ setData(json)
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : String(e))
+ }
+ }, [])
+
+ useEffect(() => {
+ void load()
+ const id = window.setInterval(() => void load(), 2000)
+ return () => clearInterval(id)
+ }, [load])
+
+ const m = data?.memory
+ const net = data?.network
+
+ return (
+
+
+ 本机负载与网速
+
+
+ 网速为全机所有网卡合计的估算值(首次刷新可能无速率,约 2 秒后出现)。下方列出当前检测到的
+ ffmpeg 进程。
+
+ {err && (
+
+ {err}
+
+ )}
+ {!data && !err && 正在读取…
}
+ {data && (
+
+
+
CPU
+
+ {data.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}
+
+
逻辑处理器 {data.cpu_count_logical ?? '—'} 个
+
+
+
内存
+
{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}
+
+ {fmtBytes(m?.used)} / {fmtBytes(m?.total)}(可用 {fmtBytes(m?.available)})
+
+
+
+
全机网速
+
+ ↑ {net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'}
+ ↓ {net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'}
+
+
含所有网卡收发合计
+
+
+ )}
+ {data?.swap && data.swap.total != null && data.swap.total > 0 && (
+
+ 交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '}
+ {fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)}
+
+ )}
+ {data?.disks && data.disks.length > 0 && (
+
+
磁盘空间
+
+ {data.disks.slice(0, 8).map((d) => (
+ -
+ {d.mountpoint}
+
+ {d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} 已用 · 剩余 {fmtBytes(d.free)}
+
+
+ ))}
+
+
+ )}
+ {data?.ffmpeg_processes && data.ffmpeg_processes.length > 0 && (
+
+
ffmpeg 进程
+
+
+
+ | PID |
+ 名称 |
+ 内存 |
+
+
+
+ {data.ffmpeg_processes.map((p) => (
+
+ | {p.pid} |
+ {p.name} |
+ {fmtBytes(p.rss)} |
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/tiktok2.py b/tiktok2.py
index 6f2da1f..21e38c9 100644
--- a/tiktok2.py
+++ b/tiktok2.py
@@ -1296,9 +1296,12 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
ffmpeg_command.insert(11, "-headers")
ffmpeg_command.insert(12, headers)
- if proxy_address:
+ _ff_proxy = (proxy_address or "").strip() or (
+ os.environ.get("STREAM_HTTP_PROXY") or ""
+ ).strip() or None
+ if _ff_proxy:
ffmpeg_command.insert(1, "-http_proxy")
- ffmpeg_command.insert(2, proxy_address)
+ ffmpeg_command.insert(2, _ff_proxy)
recording.add(record_name)
start_record_time = datetime.datetime.now()
diff --git a/web2.py b/web2.py
index 3e491f0..0d3dd3d 100644
--- a/web2.py
+++ b/web2.py
@@ -14,6 +14,9 @@ from web2_supervisor import (
strip_ansi_codes,
use_builtin,
)
+from web2_network import run_network_dashboard
+from web2_proxy_config import read_proxy_config, write_proxy_config
+from web2_system_metrics import snapshot as system_metrics_snapshot
app = FastAPI(title="多进程录制控制台")
@@ -316,6 +319,48 @@ async def health():
return {"ok": True}
+@app.get("/network_status")
+async def network_status():
+ """Google / 抖音 连通与简易网速(供仪表盘)。"""
+ try:
+ return await run_network_dashboard()
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+@app.get("/proxy_config")
+async def get_proxy_config():
+ """仅作用于录制子进程的代理(不写 Windows 系统代理)。"""
+ return read_proxy_config()
+
+
+@app.post("/proxy_config")
+async def post_proxy_config(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)
+ write_proxy_config(
+ {
+ "enabled": bool(data.get("enabled")),
+ "http": str(data.get("http") or ""),
+ "socks": str(data.get("socks") or ""),
+ }
+ )
+ return {"ok": True}
+
+
+@app.get("/system_metrics")
+async def system_metrics():
+ """CPU/内存/磁盘/网卡速率与 ffmpeg 进程摘要。"""
+ try:
+ return system_metrics_snapshot()
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
@app.get("/process_monitor")
async def process_monitor():
diff --git a/web2_network.py b/web2_network.py
new file mode 100644
index 0000000..dfa18bb
--- /dev/null
+++ b/web2_network.py
@@ -0,0 +1,267 @@
+"""
+网络检测:Google(海外)与抖音(国内)可达性、解析 IP/地区、简易上下行 Mbps。
+说明:上行依赖对端是否接受 POST,失败时返回 null 与原因,属正常现象。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import socket
+import time
+from typing import Any, Optional
+
+import httpx
+
+# ---------- 目标 ----------
+GOOGLE_HOST = "www.google.com"
+GOOGLE_CHECK_URL = "https://www.google.com/generate_204"
+GOOGLE_DOWNLOAD_URL = (
+ "https://www.gstatic.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png"
+)
+
+DOUYIN_HOST = "www.douyin.com"
+DOUYIN_CHECK_URL = "https://www.douyin.com/"
+# 首页 HTML 流足够大,便于采样测下行
+DOUYIN_DOWNLOAD_URL = "https://www.douyin.com/"
+
+OVERSEAS_UPLOAD_URL = "https://httpbin.org/post"
+DOMESTIC_UPLOAD_URLS = (
+ "https://www.douyin.com/",
+ "https://www.163.com/",
+)
+
+IP_API_TEMPLATE = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,message,query"
+
+DOWNLOAD_CAP_BYTES = 600_000
+UPLOAD_BYTES = 256 * 1024
+CONNECT_TIMEOUT = 8.0
+READ_TIMEOUT = 25.0
+
+
+def _is_probably_china_country(code: Optional[str]) -> bool:
+ return (code or "").upper() == "CN"
+
+
+async def _resolve_ipv4(host: str) -> tuple[Optional[str], Optional[str]]:
+ def _sync() -> tuple[Optional[str], Optional[str]]:
+ try:
+ infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
+ except socket.gaierror as e:
+ return None, str(e)
+ for fam, _, _, _, sockaddr in infos:
+ if fam == socket.AF_INET:
+ return sockaddr[0], None
+ for fam, _, _, _, sockaddr in infos:
+ if fam == socket.AF_INET6:
+ return sockaddr[0], None
+ return None, "no address"
+
+ return await asyncio.to_thread(_sync)
+
+
+async def _country_for_ip(client: httpx.AsyncClient, ip: str) -> dict[str, Any]:
+ if not ip:
+ return {"country": None, "country_code": None, "error": "no ip"}
+ try:
+ r = await client.get(IP_API_TEMPLATE.format(ip=ip), timeout=6.0)
+ r.raise_for_status()
+ data = r.json()
+ except Exception as e:
+ return {"country": None, "country_code": None, "error": str(e)}
+ if data.get("status") != "success":
+ return {
+ "country": None,
+ "country_code": None,
+ "error": data.get("message") or "lookup failed",
+ }
+ return {
+ "country": data.get("country"),
+ "country_code": data.get("countryCode"),
+ "error": None,
+ }
+
+
+async def _latency_get(client: httpx.AsyncClient, url: str) -> tuple[bool, float, Optional[str]]:
+ t0 = time.monotonic()
+ try:
+ r = await client.get(url, follow_redirects=True)
+ ok = r.status_code < 400
+ ms = (time.monotonic() - t0) * 1000
+ return ok, ms, None if ok else f"HTTP {r.status_code}"
+ except Exception as e:
+ ms = (time.monotonic() - t0) * 1000
+ return False, ms, str(e)
+
+
+async def _measure_download_mbps(
+ client: httpx.AsyncClient, url: str, cap: int = DOWNLOAD_CAP_BYTES
+) -> tuple[Optional[float], Optional[str], int]:
+ total = 0
+ t0 = time.monotonic()
+ try:
+ async with client.stream("GET", url, follow_redirects=True) as r:
+ r.raise_for_status()
+ async for chunk in r.aiter_bytes():
+ total += len(chunk)
+ if total >= cap:
+ break
+ dt = time.monotonic() - t0
+ if dt < 0.001 or total == 0:
+ return None, "无数据或过快", total
+ mbps = (total * 8) / dt / 1_000_000
+ return round(mbps, 2), None, total
+ except Exception as e:
+ return None, str(e), total
+
+
+async def _measure_upload_mbps(
+ client: httpx.AsyncClient, url: str, size: int = UPLOAD_BYTES
+) -> tuple[Optional[float], Optional[str]]:
+ data = b"\x00" * size
+ t0 = time.monotonic()
+ try:
+ r = await client.post(
+ url,
+ content=data,
+ headers={"Content-Type": "application/octet-stream"},
+ )
+ dt = time.monotonic() - t0
+ if dt < 0.001:
+ return None, "耗时过短"
+ mbps = (size * 8) / dt / 1_000_000
+ return round(mbps, 2), None
+ except Exception as e:
+ return None, str(e)
+
+
+async def _try_domestic_upload(client: httpx.AsyncClient) -> tuple[Optional[float], Optional[str], Optional[str]]:
+ last_err: Optional[str] = None
+ for url in DOMESTIC_UPLOAD_URLS:
+ up, err = await _measure_upload_mbps(client, url)
+ if up is not None:
+ return up, None, url
+ last_err = err
+ return None, last_err, None
+
+
+async def run_network_dashboard() -> dict[str, Any]:
+ """
+ 返回结构供前端仪表盘使用。
+ routes_different:基于解析 IP 的国家码;若 API 失败则给 likely / unknown。
+ """
+ timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT)
+ limits = httpx.Limits(max_keepalive_connections=4, max_connections=8)
+
+ ua = (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ )
+ async with httpx.AsyncClient(
+ timeout=timeout,
+ limits=limits,
+ verify=True,
+ headers={"User-Agent": ua},
+ ) as client:
+ g_ip, g_res_err = await _resolve_ipv4(GOOGLE_HOST)
+ d_ip, d_res_err = await _resolve_ipv4(DOUYIN_HOST)
+
+ async def _empty_geo() -> dict[str, Any]:
+ return {"country": None, "country_code": None, "error": "no ip"}
+
+ g_country_task = _country_for_ip(client, g_ip) if g_ip else _empty_geo()
+ d_country_task = _country_for_ip(client, d_ip) if d_ip else _empty_geo()
+
+ (
+ (g_ok, g_ms, g_err),
+ (d_ok, d_ms, d_err),
+ g_geo,
+ d_geo,
+ ) = await asyncio.gather(
+ _latency_get(client, GOOGLE_CHECK_URL),
+ _latency_get(client, DOUYIN_CHECK_URL),
+ g_country_task,
+ d_country_task,
+ )
+
+ if not isinstance(g_geo, dict):
+ g_geo = {"country": None, "country_code": None, "error": None}
+ if not isinstance(d_geo, dict):
+ d_geo = {"country": None, "country_code": None, "error": None}
+
+ g_code = g_geo.get("country_code")
+ d_code = d_geo.get("country_code")
+
+ routes_different: Optional[bool] = None
+ routes_note = ""
+ if g_code and d_code:
+ routes_different = _is_probably_china_country(g_code) != _is_probably_china_country(d_code)
+ routes_note = "依据解析 IP 的国家码:海外站点与国内站点出口不一致" if routes_different else (
+ "依据解析 IP:两国别相同或未区分境内外(若您期望分流,请检查代理/路由)"
+ )
+ else:
+ routes_note = "未能查询到双方 IP 国家,无法严格判断线路是否分流"
+ if g_ip and d_ip and g_ip != d_ip:
+ routes_note += ";两站点解析 IP 不同,通常走不同目标。"
+ if g_res_err:
+ routes_note += f" Google DNS: {g_res_err}"
+ if d_res_err:
+ routes_note += f" 抖音 DNS: {d_res_err}"
+
+ # 测速(并行)
+ od_mbps, od_err, od_bytes = await _measure_download_mbps(client, GOOGLE_DOWNLOAD_URL)
+ dd_mbps, dd_err, dd_bytes = await _measure_download_mbps(client, DOUYIN_DOWNLOAD_URL)
+
+ ou_mbps, ou_err = await _measure_upload_mbps(client, OVERSEAS_UPLOAD_URL)
+ du_mbps, du_err, du_url = await _try_domestic_upload(client)
+
+ return {
+ "google": {
+ "host": GOOGLE_HOST,
+ "reachable": g_ok,
+ "latency_ms": round(g_ms, 1),
+ "error": g_err or g_res_err,
+ "resolved_ip": g_ip,
+ "country": g_geo.get("country"),
+ "country_code": g_code,
+ "geo_error": g_geo.get("error"),
+ },
+ "douyin": {
+ "host": DOUYIN_HOST,
+ "reachable": d_ok,
+ "latency_ms": round(d_ms, 1),
+ "error": d_err or d_res_err,
+ "resolved_ip": d_ip,
+ "country": d_geo.get("country"),
+ "country_code": d_code,
+ "geo_error": d_geo.get("error"),
+ },
+ "routes": {
+ "different": routes_different,
+ "note": routes_note.strip(),
+ },
+ "speed": {
+ "overseas": {
+ "label": "海外(Google / gstatic)",
+ "download_mbps": od_mbps,
+ "download_error": od_err,
+ "download_bytes_sampled": od_bytes,
+ "upload_mbps": ou_mbps,
+ "upload_error": ou_err,
+ "upload_target": OVERSEAS_UPLOAD_URL,
+ },
+ "domestic": {
+ "label": "国内(字节 CDN / 门户)",
+ "download_mbps": dd_mbps,
+ "download_error": dd_err,
+ "download_bytes_sampled": dd_bytes,
+ "upload_mbps": du_mbps,
+ "upload_error": du_err,
+ "upload_target": du_url,
+ },
+ },
+ "meta": {
+ "at": time.time(),
+ "download_cap_bytes": DOWNLOAD_CAP_BYTES,
+ "upload_bytes": UPLOAD_BYTES,
+ },
+ }
diff --git a/web2_proxy_config.py b/web2_proxy_config.py
new file mode 100644
index 0000000..3b59e03
--- /dev/null
+++ b/web2_proxy_config.py
@@ -0,0 +1,96 @@
+"""
+仅给「录制子进程」使用的代理配置(FFmpeg / youtube2 等),不修改 Windows 系统代理。
+用户在本机运行 Clash Verge 等时,通常填 http://127.0.0.1:7890 与 socks5://127.0.0.1:7890。
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+from configparser import ConfigParser
+
+BASE_DIR = Path(__file__).resolve().parent
+PROXY_INI = BASE_DIR / "config" / "ffmpeg_proxy.ini"
+
+
+def _default_ini_text() -> str:
+ return (
+ "[proxy]\n"
+ "# 开启后仅作用于本软件拉起的录制/推流进程,不会改系统代理\n"
+ "enabled = no\n"
+ "# 与 Clash Verge「混合端口」一致即可(示例)\n"
+ "http = http://127.0.0.1:7890\n"
+ "socks = socks5://127.0.0.1:7890\n"
+ )
+
+
+def ensure_proxy_config_file() -> None:
+ PROXY_INI.parent.mkdir(parents=True, exist_ok=True)
+ if not PROXY_INI.exists():
+ PROXY_INI.write_text(_default_ini_text(), encoding="utf-8")
+
+
+def read_proxy_config() -> dict[str, Any]:
+ ensure_proxy_config_file()
+ cp = ConfigParser()
+ cp.read(PROXY_INI, encoding="utf-8")
+ if not cp.has_section("proxy"):
+ cp.add_section("proxy")
+ enabled = cp.get("proxy", "enabled", fallback="no").strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ "on",
+ "是",
+ "开",
+ )
+ http = (cp.get("proxy", "http", fallback="") or "").strip()
+ socks = (cp.get("proxy", "socks", fallback="") or "").strip()
+ return {"enabled": enabled, "http": http, "socks": socks}
+
+
+def write_proxy_config(data: dict[str, Any]) -> None:
+ ensure_proxy_config_file()
+ cp = ConfigParser()
+ cp.read(PROXY_INI, encoding="utf-8")
+ if not cp.has_section("proxy"):
+ cp.add_section("proxy")
+ cp.set("proxy", "enabled", "yes" if data.get("enabled") else "no")
+ cp.set("proxy", "http", str(data.get("http") or "").strip())
+ cp.set("proxy", "socks", str(data.get("socks") or "").strip())
+ with open(PROXY_INI, "w", encoding="utf-8") as f:
+ cp.write(f)
+
+
+def subprocess_env_overlay() -> dict[str, str]:
+ """
+ 合并进子进程 environ 的键值(不含整个 os.environ)。
+ youtube2 / tiktok2 会读 STREAM_HTTP_PROXY / STREAM_SOCKS_PROXY。
+ """
+ cfg = read_proxy_config()
+ out: dict[str, str] = {}
+ if not cfg["enabled"]:
+ return out
+ http = cfg["http"]
+ socks = cfg["socks"]
+ if http:
+ out["STREAM_HTTP_PROXY"] = http
+ out["HTTP_PROXY"] = http
+ out["HTTPS_PROXY"] = http
+ out["http_proxy"] = http
+ out["https_proxy"] = http
+ if socks:
+ out["STREAM_SOCKS_PROXY"] = socks
+ out["ALL_PROXY"] = socks
+ out["all_proxy"] = socks
+ return out
+
+
+def merge_child_env(base: dict[str, str] | None = None) -> dict[str, str]:
+ """供 asyncio subprocess / Popen 使用(完整继承系统环境后再叠加代理)。"""
+ env = dict(os.environ)
+ if base:
+ env.update(base)
+ env.update(subprocess_env_overlay())
+ return env
diff --git a/web2_supervisor.py b/web2_supervisor.py
index 7892f85..7feff3a 100644
--- a/web2_supervisor.py
+++ b/web2_supervisor.py
@@ -15,6 +15,8 @@ from typing import Any, Callable, Optional
import psutil
+from web2_proxy_config import merge_child_env
+
def use_builtin() -> bool:
return os.environ.get("WEB2_SUPERVISOR", "builtin").strip().lower() != "pm2"
@@ -114,6 +116,7 @@ class BuiltinSupervisor:
stdout=out_f,
stderr=err_f,
creationflags=0,
+ env=merge_child_env(),
)
except Exception as e:
out_f.close()
diff --git a/web2_system_metrics.py b/web2_system_metrics.py
new file mode 100644
index 0000000..ad815c2
--- /dev/null
+++ b/web2_system_metrics.py
@@ -0,0 +1,129 @@
+"""
+系统与网卡指标(供仪表盘轮询)。下行/上行为全机网卡合计速率(估算)。
+"""
+from __future__ import annotations
+
+import time
+from typing import Any, Optional
+
+import psutil
+
+_last_net_time: float = 0.0
+_last_net: Optional[dict[str, tuple[int, int]]] = None # name -> (sent, recv)
+
+
+def _net_rates_mbps() -> tuple[Optional[float], Optional[float], dict[str, Any]]:
+ """返回 (上行 Mbps, 下行 Mbps, 原始片段)。"""
+ global _last_net_time, _last_net
+ now = time.monotonic()
+ per = psutil.net_io_counters(pernic=True)
+ cur: dict[str, tuple[int, int]] = {k: (v.bytes_sent, v.bytes_recv) for k, v in per.items()}
+ detail: dict[str, Any] = {}
+ up_mbps = down_mbps = None
+ if _last_net is not None and _last_net_time > 0:
+ dt = now - _last_net_time
+ if dt > 0.05:
+ ds = dr = 0
+ for name, (sent, recv) in cur.items():
+ if name in _last_net:
+ ps0, pr0 = _last_net[name]
+ ds += max(0, sent - ps0)
+ dr += max(0, recv - pr0)
+ up_mbps = round((ds * 8) / dt / 1_000_000, 3)
+ down_mbps = round((dr * 8) / dt / 1_000_000, 3)
+ detail["delta_seconds"] = round(dt, 3)
+ _last_net = cur
+ _last_net_time = now
+ for name, (sent, recv) in cur.items():
+ detail.setdefault("interfaces", {})[name] = {
+ "bytes_sent": sent,
+ "bytes_recv": recv,
+ }
+ return up_mbps, down_mbps, detail
+
+
+def snapshot() -> dict[str, Any]:
+ try:
+ psutil.cpu_percent(interval=None)
+ cpu = psutil.cpu_percent(interval=0.15)
+ except Exception:
+ cpu = None
+ try:
+ vm = psutil.virtual_memory()
+ mem = {
+ "percent": vm.percent,
+ "used": vm.used,
+ "total": vm.total,
+ "available": vm.available,
+ }
+ except Exception:
+ mem = {}
+ try:
+ sm = psutil.swap_memory()
+ swap = {"percent": sm.percent, "used": sm.used, "total": sm.total}
+ except Exception:
+ swap = {}
+
+ disks = []
+ try:
+ for part in psutil.disk_partitions(all=False):
+ try:
+ usage = psutil.disk_usage(part.mountpoint)
+ disks.append(
+ {
+ "device": part.device,
+ "mountpoint": part.mountpoint,
+ "fstype": part.fstype,
+ "percent": usage.percent,
+ "free": usage.free,
+ "total": usage.total,
+ }
+ )
+ except (PermissionError, OSError):
+ continue
+ except Exception:
+ pass
+
+ up_mbps, down_mbps, net_detail = _net_rates_mbps()
+
+ boot = None
+ try:
+ boot = int(psutil.boot_time())
+ except Exception:
+ pass
+
+ ffmpeg_procs = []
+ try:
+ for p in psutil.process_iter(["pid", "name", "memory_info"]):
+ try:
+ name = (p.info.get("name") or "").lower()
+ if "ffmpeg" not in name:
+ continue
+ mi = p.info.get("memory_info")
+ ffmpeg_procs.append(
+ {
+ "pid": p.info["pid"],
+ "name": p.info.get("name"),
+ "rss": mi.rss if mi else 0,
+ }
+ )
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ continue
+ except Exception:
+ pass
+
+ return {
+ "ts": time.time(),
+ "cpu_percent": cpu,
+ "cpu_count_logical": psutil.cpu_count(logical=True),
+ "memory": mem,
+ "swap": swap,
+ "disks": disks,
+ "network": {
+ "up_mbps": up_mbps,
+ "down_mbps": down_mbps,
+ "detail": net_detail,
+ },
+ "boot_time_unix": boot,
+ "ffmpeg_processes": ffmpeg_procs[:24],
+ }
diff --git a/youtube2.py b/youtube2.py
index 1b894ce..9f39aeb 100644
--- a/youtube2.py
+++ b/youtube2.py
@@ -1298,9 +1298,12 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
ffmpeg_command.insert(11, "-headers")
ffmpeg_command.insert(12, headers)
- if proxy_address:
+ _ff_proxy = (proxy_address or "").strip() or (
+ os.environ.get("STREAM_HTTP_PROXY") or ""
+ ).strip() or None
+ if _ff_proxy:
ffmpeg_command.insert(1, "-http_proxy")
- ffmpeg_command.insert(2, proxy_address)
+ ffmpeg_command.insert(2, _ff_proxy)
recording.add(record_name)
start_record_time = datetime.datetime.now()