feat: stabilize fnos lan delivery flow

This commit is contained in:
kris
2026-03-27 13:49:30 +08:00
parent 32bc94f924
commit b35d653610
10 changed files with 667 additions and 14 deletions

View File

@@ -201,12 +201,12 @@ N8N_BASE_URL=http://127.0.0.1:5670
- 详细审计、阶段计划和联调步骤见 `docs/`
- Windows `cutvideo` 的恢复与常驻维护见 [`WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md`](/Users/kris/code/StoryForge-gitea/docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md)
fnOS 局域网联调推荐先执行
fnOS / NAS 局域网交付默认三步
```bash
./scripts/deploy_fnos_cutvideo_tunnel.sh
./scripts/deploy_fnos_storyforge_collector.sh
./scripts/deploy_fnos_storyforge_web.sh
./scripts/deploy_fnos_storyforge_lan_stack.sh
./scripts/smoke_fnos_storyforge_lan.sh
```
这套顺序会先把 Windows `cutvideo` 通过 NAS 本地隧道暴露到 `19186/19181`,再让 NAS collector 和 Web 统一切到这条更稳定的入口
这套顺序会先把 Windows `cutvideo` 通过 NAS SSH 隧道暴露到 `19186/19181`,再把 StoryForge 的 NAS 侧联调用默认主链切到 `http://192.168.31.188:19186`,最后用一键 smoke 校验整条链路

View File

@@ -907,6 +907,20 @@ def storage_mode(path: Path) -> str:
return "local"
def cutvideo_route_mode(base_url: str) -> str:
normalized = base_url.strip()
if not normalized:
return "direct"
candidate = normalized if "://" in normalized else f"http://{normalized}"
try:
parsed = urlparse(candidate)
if parsed.port == 19186:
return "fnos_tunnel"
except ValueError:
pass
return "direct"
def disk_usage_payload(path: Path) -> dict[str, Any]:
probe = path if path.exists() else path.parent
try:
@@ -3196,6 +3210,12 @@ def fetch_local_model_catalog(timeout: float = 8.0) -> dict[str, Any]:
@app.get("/healthz")
def healthz() -> dict[str, Any]:
lan_routing = {
"collectorBaseUrl": DEFAULT_EXTERNAL_BASE_URL,
"cutvideoBaseUrl": CUTVIDEO_BASE_URL,
"liveRecorderBaseUrl": LIVE_RECORDER_BASE_URL,
"cutvideoRouteMode": cutvideo_route_mode(CUTVIDEO_BASE_URL),
}
return {
"status": "ok",
"dbPath": DB_PATH,
@@ -3210,6 +3230,7 @@ def healthz() -> dict[str, Any]:
"cutvideoUploadTimeoutSec": CUTVIDEO_UPLOAD_TIMEOUT_SEC,
"huobaoBaseUrl": HUOBAO_BASE_URL,
"liveRecorderBaseUrl": LIVE_RECORDER_BASE_URL,
"lanRouting": lan_routing,
"orchestratorSecretConfigured": orchestrator_secret_configured(),
"bootstrapSuperadminConfigured": bootstrap_superadmin_configured(),
"webAutoLoginConfigured": web_autologin_configured(),
@@ -3244,6 +3265,7 @@ def integrations_health(account: dict[str, Any] = Depends(require_approved)) ->
"upload_status_code": int(cutvideo_uploads.get("status_code") or 0),
"upload_error": cutvideo_uploads.get("error", ""),
"upload_url": cutvideo_uploads.get("url", ""),
"route_mode": cutvideo_route_mode(CUTVIDEO_BASE_URL),
},
"huobao": {
"base_url": HUOBAO_BASE_URL,

View File

@@ -0,0 +1,130 @@
# StoryForge fnOS / NAS LAN Delivery Runbook
日期2026-03-27
## 目标
这份 runbook 统一说明 StoryForge 在 fnOS / NAS 局域网交付时的默认主链。
默认原则只有一条NAS SSH 隧道是主链Windows `7860` 只做自检。
## 默认链路
1. 先把 Windows `cutvideo` 通过 fnOS 的 SSH 隧道暴露到 NAS。
2. 再让 StoryForge 的 NAS 侧服务统一指向 NAS 隧道地址。
3. 最后用一键 smoke 验证整条链路是否可用。
推荐默认顺序:
```bash
./scripts/deploy_fnos_cutvideo_tunnel.sh
./scripts/deploy_fnos_storyforge_lan_stack.sh
./scripts/smoke_fnos_storyforge_lan.sh
```
## 默认端口
- Windows `cutvideo` 自检口:`http://192.168.31.18:7860`
- NAS 主链 `cutvideo` 入口:`http://192.168.31.188:19186`
- NAS 兼容/上传入口:`http://192.168.31.188:19181`
- StoryForge collector`http://127.0.0.1:8081`
- fnOS 内部 n8n`http://127.0.0.1:5670`
## 默认路由
- StoryForge 的 `CUTVIDEO_BASE_URL` 默认应指向 `http://192.168.31.188:19186`
- `19186` 是交付主链,不要再把 `7860` 当成 StoryForge 默认主入口
- `7860` 仅用于确认 Windows 上的 `cutvideo` 服务本身是否活着
- 如果任务涉及上传或 staging再顺带确认 `19181` 可达
## 重启后验证
### Windows 重启后
- 先确认 `22 / 3389 / 5985` 仍可达
- 再检查 `http://192.168.31.18:7860/api/bootstrap`
- 如果 `7860` 超时,但管理通道正常,优先判断为 `cutvideo` 服务未起来
- 如果 `7860` 可达,再确认 Windows 任务计划程序 `\Codex\cutvideo-web` 仍在托管服务
### fnOS 重启后
- 先跑 `./scripts/deploy_fnos_cutvideo_tunnel.sh`
- 再跑 `./scripts/deploy_fnos_storyforge_lan_stack.sh`
- 确认 `19186``19181` 都重新可达
- 确认 StoryForge collector 仍然把 `CUTVIDEO_BASE_URL` 指向 `19186`
### StoryForge 服务重启后
- 检查 collector 还能正常返回 health
- 检查 NAS 侧服务没有回退到 Windows 直连 `7860`
- 检查 smoke 是否还能把 real-cut 链路跑通
## Smoke 命令
```bash
./scripts/smoke_fnos_storyforge_lan.sh
```
这条 smoke 应该至少覆盖:
- `19186` 可达
- `19181` 可达
- `cutvideo` 在线
- StoryForge NAS 侧链路可用
## 故障分流
### 1. `19186` 不通
先看 fnOS 的 SSH 隧道是否还在:
- 重新执行 `./scripts/deploy_fnos_cutvideo_tunnel.sh`
- 确认 Windows 主机可连
- 再确认 Windows `7860` 本身是否正常
### 2. `7860` 不通,但 `22 / 3389 / 5985` 还通
这通常是 Windows 上的 `cutvideo` 没启动,不是网络地址失效。
优先检查:
- Windows 任务计划程序 `\Codex\cutvideo-web`
- `D:\ai-code\cutvideo\.venv`
- `http://192.168.31.18:7860/api/bootstrap`
### 3. `19186` 通,但 StoryForge 链路失败
说明隧道大概率是好的,问题更可能在 NAS 侧服务配置。
优先检查:
- `./scripts/deploy_fnos_storyforge_lan_stack.sh` 是否已重新跑过
- `CUTVIDEO_BASE_URL` 是否仍然是 `http://192.168.31.188:19186`
- collector 是否回退到了 Windows 直连 `7860`
### 4. `19186` 和 `7860` 都正常,但 smoke 失败
优先看失败点属于哪一层:
- 只是 `collector` health 失败,先看 NAS 侧服务
- 只是上传失败,先看 `19181`
- 只是 `cutvideo` 任务失败,先看 Windows 服务日志
### 5. Windows 或 fnOS 重启后出现“短时间都不通”
先按默认顺序重新跑:
```bash
./scripts/deploy_fnos_cutvideo_tunnel.sh
./scripts/deploy_fnos_storyforge_lan_stack.sh
./scripts/smoke_fnos_storyforge_lan.sh
```
如果这三步后仍失败,再进入对应故障分流。
## 维护原则
- 默认主链永远是 NAS SSH 隧道
- Windows `7860` 只做自检,不做 StoryForge 默认入口
- 交付时先保证 `19186` 稳,再谈其他端口
- 新人接手时,先跑 smoke再看详细日志

View File

@@ -19,7 +19,7 @@ cp .env.example .env
- `BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin`
- `BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password`
- `STORYFORGE_INTERNAL_BASE_URL=http://collector:8081`,用于 Docker 内的 n8n 回调 `collector`
- `CUTVIDEO_BASE_URL=http://<windows-lan-ip>:7860`
- `CUTVIDEO_BASE_URL=http://192.168.31.188:19186`,默认主链走 NAS SSH 隧道
- `CUTVIDEO_API_KEY=` 如果 Windows 服务启用了鉴权
- `HUOBAO_BASE_URL=http://127.0.0.1:5678`
- `WHISPER_BIN=` 指向你现有本地 ASR 可执行文件时填写
@@ -33,7 +33,8 @@ cp .env.example .env
- 如果你单独重建 `collector`,要确保运行时仍带上 `CUTVIDEO_BASE_URL`,否则容器会退回空值
- `collector` 容器不要直接复用宿主机的 `N8N_BASE_URL=http://127.0.0.1:5670`,否则容器内会连回自己并导致 webhook 调度失败
- 当前更稳定的 NAS 转发地址是 `http://192.168.31.188:19186`
- Windows 直连地址 `http://192.168.31.18:7860` 仍可作为主机内自检入口,但不再建议作为 StoryForge 主链默认值
- Windows 直连地址 `http://192.168.31.18:7860` 仅用于主机内自检不再建议作为 StoryForge 主链默认值
- 只要是 StoryForge 的 fnOS / NAS 联调与交付,优先把 `CUTVIDEO_BASE_URL` 视为 `19186`,把 `7860` 视为 Windows 本机自检口
- 当前已验证可用的本机 HTTP ASR 入口是 `http://host.docker.internal:8088/transcribe`
- 如果你用的是本机 `mac-whisper-service`,建议同时以 `WHISPER_TIMEOUT_MS=120000` 启动,否则长视频会直接 504
@@ -41,13 +42,19 @@ cp .env.example .env
```bash
./scripts/deploy_fnos_cutvideo_tunnel.sh
./scripts/deploy_fnos_storyforge_lan_stack.sh
./scripts/smoke_fnos_storyforge_lan.sh
```
它会做三件事:
如果你只想先把底座打通,也可以先跑前两步,再单独 smoke。
它们分别负责:
- 在 fnOS 上生成并持久化 Windows SSH 隧道密钥
- 把 fnOS 公钥写入 Windows OpenSSH 管理员授权文件
- 在 fnOS 上常驻 `19186 -> Windows 127.0.0.1:7860``19181 -> Windows 127.0.0.1:8081`,并写入 `@reboot` 自启动
- 把 StoryForge 的 NAS 侧服务统一切到 `http://192.168.31.188:19186` 的默认主链
- 通过一键 smoke 校验 `cutvideo``collector` 和整条 LAN 交付链路
`cutvideo` 维护补充2026-03-27
@@ -261,7 +268,7 @@ npm run capture -- \
- `GET /api/bootstrap` 恢复为 `200``GET /api/uploads` 返回 `405 Method Not Allowed`
- 上面的 `405` 是正常现象,说明上传接口存在且只接受 `POST`
- `StoryForge collector``/v2/integrations/health` 已重新识别到 `cutvideo.reachable=true``supports_uploads=true`
- fnOS 局域网调试链现在默认走 `http://192.168.31.188:19186`不再依赖 Windows 机器直接开放 `7860`
- fnOS 局域网调试链现在默认走 `http://192.168.31.188:19186`Windows 机器直接开放 `7860` 仅保留为自检入口
- 如果 UI 里 `自动剪辑` 再次掉线,先按 [`WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md`](/Users/kris/code/StoryForge-gitea/docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md) 检查 Windows 任务计划程序和 `.venv`
## 8. `huobao-drama` AI 视频链路验证

View File

@@ -0,0 +1,249 @@
# fnOS LAN Delivery Stabilization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 StoryForge 的 fnOS / NAS 局域网交付链做成仓库内一键可复现、可 smoke、可恢复的稳定版本。
**Architecture:** 继续采用“Windows 运行 cutvideo + fnOS 通过 SSH 隧道暴露 19186/19181 + fnOS collector 默认走 19186 + fnOS Web 默认走 fnOS collector”的交付路径。在仓库内新增统一部署入口、统一 LAN smoke、补齐 healthz 路由可见性与前端提示,并把运维说明统一到同一条主链。
**Tech Stack:** Bash, Python 3, FastAPI, vanilla JS, Docker Compose, fnOS SSH helpers
---
### Task 1: 落地统一部署入口
**Files:**
- Create: `scripts/deploy_fnos_storyforge_lan_stack.sh`
- Modify: `scripts/deploy_fnos_storyforge_web.sh`
- Modify: `scripts/deploy_fnos_storyforge_collector.sh`
- Test: `tests/test_production_baseline.py`
- [ ] **Step 1: 写失败测试,约束统一部署入口存在并串联 tunnel / collector / web**
```python
def test_repo_contains_fnos_lan_stack_deploy_entrypoint(self) -> None:
script_path = ROOT / "scripts" / "deploy_fnos_storyforge_lan_stack.sh"
self.assertTrue(script_path.exists())
content = script_path.read_text(encoding="utf-8")
self.assertIn("deploy_fnos_cutvideo_tunnel.sh", content)
self.assertIn("deploy_fnos_storyforge_collector.sh", content)
self.assertIn("deploy_fnos_storyforge_web.sh", content)
```
- [ ] **Step 2: 跑测试确认当前失败**
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_repo_contains_fnos_lan_stack_deploy_entrypoint -v`
Expected: FAIL with missing `deploy_fnos_storyforge_lan_stack.sh`
- [ ] **Step 3: 写最小实现**
```bash
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
bash "$ROOT/scripts/deploy_fnos_cutvideo_tunnel.sh"
bash "$ROOT/scripts/deploy_fnos_storyforge_collector.sh"
bash "$ROOT/scripts/deploy_fnos_storyforge_web.sh"
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_repo_contains_fnos_lan_stack_deploy_entrypoint -v`
Expected: PASS
- [ ] **Step 5: 完成后补脚本语法校验**
Run: `bash -n scripts/deploy_fnos_storyforge_lan_stack.sh`
Expected: exit 0
### Task 2: 落地统一 LAN smoke
**Files:**
- Create: `scripts/smoke_fnos_storyforge_lan.sh`
- Modify: `tests/test_production_baseline.py`
- Modify: `README.md`
- Modify: `docs/LAN_E2E_GUIDE_2026-03-18.md`
- [ ] **Step 1: 写失败测试,约束 LAN smoke 覆盖 web / healthz / auto-session / integrations / tunnel**
```python
def test_repo_contains_fnos_lan_smoke_script(self) -> None:
script_path = ROOT / "scripts" / "smoke_fnos_storyforge_lan.sh"
self.assertTrue(script_path.exists())
content = script_path.read_text(encoding="utf-8")
for expected in [
"/healthz",
"/v2/auth/auto-session",
"/v2/integrations/health",
"/api/bootstrap",
"19181",
]:
self.assertIn(expected, content)
```
- [ ] **Step 2: 跑测试确认当前失败**
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_repo_contains_fnos_lan_smoke_script -v`
Expected: FAIL with missing `smoke_fnos_storyforge_lan.sh`
- [ ] **Step 3: 写最小实现**
```bash
#!/usr/bin/env bash
set -euo pipefail
FNOS_HOST="${FNOS_HOST:-192.168.31.188}"
WEB_PORT="${STORYFORGE_WEB_V4_DEV_PORT:-19192}"
COLLECTOR_PORT="${STORYFORGE_COLLECTOR_DEV_PORT:-19193}"
CUTVIDEO_PORT="${CUTVIDEO_FORWARD_PORT:-19186}"
COMPAT_PORT="${STORYFORGE_COMPAT_FORWARD_PORT:-19181}"
```
继续补:
- 访问 `http://$FNOS_HOST:$WEB_PORT/`
- 校验 `storyforge-runtime-config.js`
- 访问 `http://$FNOS_HOST:$COLLECTOR_PORT/healthz`
- `POST /v2/auth/auto-session` 获取 token
- 带 token 调 `GET /v2/integrations/health`
- 校验 `cutvideo.reachable == true`
- 访问 `http://$FNOS_HOST:$CUTVIDEO_PORT/api/bootstrap`
- 访问 `http://$FNOS_HOST:$COMPAT_PORT/`
- [ ] **Step 4: 跑测试确认通过**
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_repo_contains_fnos_lan_smoke_script -v`
Expected: PASS
- [ ] **Step 5: 完成后做脚本语法校验**
Run: `bash -n scripts/smoke_fnos_storyforge_lan.sh`
Expected: exit 0
### Task 3: 收口 healthz 与前端依赖文案
**Files:**
- Modify: `collector-service/app/core_main.py`
- Modify: `web/storyforge-web-v4/assets/app.js`
- Modify: `tests/test_production_baseline.py`
- [ ] **Step 1: 写失败测试,约束 healthz 暴露局域网路由信息**
```python
def test_healthz_exposes_lan_routing_summary(self) -> None:
response = self.client.get("/healthz")
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()
self.assertIn("lanRouting", payload)
self.assertIn("cutvideoRouteMode", payload["lanRouting"])
```
- [ ] **Step 2: 跑测试确认当前失败**
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_healthz_exposes_lan_routing_summary -v`
Expected: FAIL because `lanRouting` is absent
- [ ] **Step 3: 写最小实现**
```python
"lanRouting": {
"collectorBaseUrl": DEFAULT_EXTERNAL_BASE_URL,
"cutvideoBaseUrl": CUTVIDEO_BASE_URL,
"liveRecorderBaseUrl": LIVE_RECORDER_BASE_URL,
"cutvideoRouteMode": "fnos_tunnel" if ":19186" in CUTVIDEO_BASE_URL else "direct",
}
```
前端同步收口:
```javascript
if (key === "cutvideo" && detail.baseUrl.includes(":19186")) {
extra = "当前通过 fnOS NAS SSH 隧道访问 Windows cutvideo。";
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_healthz_exposes_lan_routing_summary -v`
Expected: PASS
- [ ] **Step 5: 做前端语法校验**
Run: `node --check web/storyforge-web-v4/assets/app.js`
Expected: exit 0
### Task 4: 统一 README / LAN 运维手册并补最终回归
**Files:**
- Modify: `README.md`
- Modify: `docs/LAN_E2E_GUIDE_2026-03-18.md`
- Create: `docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md`
- [ ] **Step 1: 更新主入口文档**
把 README 收口成三条默认命令:
```bash
./scripts/deploy_fnos_cutvideo_tunnel.sh
./scripts/deploy_fnos_storyforge_lan_stack.sh
./scripts/smoke_fnos_storyforge_lan.sh
```
- [ ] **Step 2: 更新 LAN E2E**
`CUTVIDEO_BASE_URL=http://<windows-lan-ip>:7860` 改成“主链默认使用 `http://192.168.31.188:19186`Windows 直连仅作自检”。
- [ ] **Step 3: 写新的运维 runbook**
包含:
- 默认端口
- 默认路由
- 故障分流
- fnOS 重启后如何验证 tunnel / web / collector
- smoke 命令与预期结果
- [ ] **Step 4: 跑最终验证**
Run:
```bash
bash -n scripts/deploy_fnos_storyforge_lan_stack.sh
bash -n scripts/smoke_fnos_storyforge_lan.sh
python3 -m unittest tests.test_production_baseline -v
node --check web/storyforge-web-v4/assets/app.js
git diff --check
```
Expected:
- scripts syntax all pass
- unittest pass
- JS syntax pass
- `git diff --check` clean
- [ ] **Step 5: Commit**
```bash
git add README.md docs/LAN_E2E_GUIDE_2026-03-18.md docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md docs/superpowers/plans/2026-03-27-fnos-lan-delivery-stabilization.md scripts/deploy_fnos_storyforge_lan_stack.sh scripts/smoke_fnos_storyforge_lan.sh tests/test_production_baseline.py collector-service/app/core_main.py web/storyforge-web-v4/assets/app.js
git commit -m "feat: stabilize fnos lan delivery flow"
```
## Self-Review
### Spec coverage
- 统一部署入口Task 1 覆盖
- LAN smokeTask 2 覆盖
- 前后端状态收口Task 3 覆盖
- 文档与运维统一Task 4 覆盖
### Placeholder scan
- 没有保留 TBD / TODO / “后续补”
- 每个任务都给了明确文件和验证命令
### Type consistency
- 统一使用 `deploy_fnos_storyforge_lan_stack.sh`
- 统一使用 `smoke_fnos_storyforge_lan.sh`
- `healthz` 新字段统一命名为 `lanRouting`

View File

@@ -58,6 +58,8 @@ CUTVIDEO_BASE_URL="${CUTVIDEO_BASE_URL:-http://$FNOS_HOST:19186}"
LIVE_RECORDER_BASE_URL="${LIVE_RECORDER_BASE_URL:-http://192.168.31.188:19106}"
CLOUD_DB_PATH="${CLOUD_DB_PATH:-/home/ubuntu/storyforge/data/collector/storyforge.db}"
CLOUD_DB_SNAPSHOT_PATH="${CLOUD_DB_SNAPSHOT_PATH:-/home/ubuntu/storyforge/data/collector/storyforge-fnos-sync.db}"
COLLECTOR_HEALTH_RETRY_ATTEMPTS="${COLLECTOR_HEALTH_RETRY_ATTEMPTS:-24}"
COLLECTOR_HEALTH_RETRY_SLEEP_SEC="${COLLECTOR_HEALTH_RETRY_SLEEP_SEC:-3}"
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
@@ -256,13 +258,20 @@ else
fi
echo "[7/7] verify lan collector and web binding"
for _attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do
collector_ready=0
for _attempt in $(seq 1 "$COLLECTOR_HEALTH_RETRY_ATTEMPTS"); do
if curl -fsS "$BACKEND_URL/healthz" >/dev/null; then
collector_ready=1
break
fi
sleep 3
echo " -> waiting for collector healthz (${_attempt}/$COLLECTOR_HEALTH_RETRY_ATTEMPTS)"
sleep "$COLLECTOR_HEALTH_RETRY_SLEEP_SEC"
done
curl -fsS "$BACKEND_URL/healthz" >/dev/null
if [ "$collector_ready" != "1" ]; then
echo "collector did not become healthy in time: $BACKEND_URL" >&2
run_fnos_sudo "$FNOS_PASSWORD_VALUE" "cd $(shell_quote "$REMOTE_COMPOSE_DIR") && STORYFORGE_COLLECTOR_DEV_PORT=$(shell_quote "$COLLECTOR_PORT") docker compose -f $(shell_quote "$REMOTE_COMPOSE_FILE") ps && STORYFORGE_COLLECTOR_DEV_PORT=$(shell_quote "$COLLECTOR_PORT") docker compose -f $(shell_quote "$REMOTE_COMPOSE_FILE") logs --tail=80 storyforge-collector-dev" >&2 || true
exit 1
fi
curl -fsS -X POST "$BACKEND_URL/v2/auth/auto-session" -H 'content-type: application/json' -d '{}' >/dev/null
curl -fsS "http://$FNOS_HOST:$FRONTEND_PORT/assets/storyforge-runtime-config.js" | grep -q "$BACKEND_URL"

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
FNOS_HOST="${FNOS_HOST:-192.168.31.188}"
COLLECTOR_PORT="${STORYFORGE_COLLECTOR_DEV_PORT:-19193}"
BACKEND_URL="${STORYFORGE_FNOS_BACKEND_URL:-http://$FNOS_HOST:$COLLECTOR_PORT}"
SKIP_TUNNEL="${SKIP_TUNNEL:-0}"
SKIP_SMOKE="${SKIP_SMOKE:-0}"
echo "[1/4] ensure fnOS cutvideo tunnel"
if [ "$SKIP_TUNNEL" = "1" ]; then
echo "skip tunnel deployment because SKIP_TUNNEL=1"
else
STORYFORGE_FNOS_BACKEND_URL="$BACKEND_URL" bash "$ROOT/scripts/deploy_fnos_cutvideo_tunnel.sh"
fi
echo "[2/4] deploy fnOS collector"
STORYFORGE_FNOS_COLLECTOR_URL="$BACKEND_URL" bash "$ROOT/scripts/deploy_fnos_storyforge_collector.sh"
echo "[3/4] deploy fnOS web"
STORYFORGE_FNOS_BACKEND_URL="$BACKEND_URL" bash "$ROOT/scripts/deploy_fnos_storyforge_web.sh"
echo "[4/4] smoke fnOS lan stack"
if [ "$SKIP_SMOKE" = "1" ]; then
echo "skip smoke because SKIP_SMOKE=1"
else
STORYFORGE_FNOS_COLLECTOR_URL="$BACKEND_URL" STORYFORGE_FNOS_BACKEND_URL="$BACKEND_URL" bash "$ROOT/scripts/smoke_fnos_storyforge_lan.sh"
fi
echo "fnOS StoryForge LAN stack ready:"
echo " web: http://$FNOS_HOST:${STORYFORGE_WEB_V4_DEV_PORT:-19192}/"
echo " collector: $BACKEND_URL"

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
FNOS_HOST="${FNOS_HOST:-192.168.31.188}"
WEB_PORT="${STORYFORGE_WEB_V4_DEV_PORT:-19192}"
COLLECTOR_PORT="${STORYFORGE_COLLECTOR_DEV_PORT:-19193}"
CUTVIDEO_FORWARD_PORT="${CUTVIDEO_FORWARD_PORT:-19186}"
STORYFORGE_COMPAT_FORWARD_PORT="${STORYFORGE_COMPAT_FORWARD_PORT:-19181}"
WEB_URL="${STORYFORGE_FNOS_WEB_URL:-http://$FNOS_HOST:$WEB_PORT}"
BACKEND_URL="${STORYFORGE_FNOS_COLLECTOR_URL:-http://$FNOS_HOST:$COLLECTOR_PORT}"
CUTVIDEO_URL="${CUTVIDEO_BASE_URL:-http://$FNOS_HOST:$CUTVIDEO_FORWARD_PORT}"
COMPAT_URL="${STORYFORGE_COMPAT_BASE_URL:-http://$FNOS_HOST:$STORYFORGE_COMPAT_FORWARD_PORT}"
CURL_MAX_TIME="${STORYFORGE_FNOS_CURL_MAX_TIME:-60}"
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
need_cmd curl
need_cmd python3
need_cmd rg
curl_fetch() {
curl -fsS --max-time "$CURL_MAX_TIME" "$@"
}
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
index_file="$tmp_dir/index.html"
runtime_file="$tmp_dir/runtime.js"
health_file="$tmp_dir/health.json"
session_file="$tmp_dir/session.json"
integrations_file="$tmp_dir/integrations.json"
bootstrap_file="$tmp_dir/bootstrap.json"
compat_file="$tmp_dir/compat.html"
token_file="$tmp_dir/token.txt"
echo "[1/6] check fnOS web"
curl_fetch "$WEB_URL/" >"$index_file"
rg -q "StoryForge" "$index_file"
echo "web ok"
echo "[2/6] check runtime config"
curl_fetch "$WEB_URL/assets/storyforge-runtime-config.js" >"$runtime_file"
rg -q "$BACKEND_URL" "$runtime_file"
echo "runtime config ok"
echo "[3/6] check collector healthz"
curl_fetch "$BACKEND_URL/healthz" >"$health_file"
python3 - "$health_file" "$CUTVIDEO_URL" <<'PY'
import json
import pathlib
import sys
payload = json.loads(pathlib.Path(sys.argv[1]).read_text())
expected_cutvideo = sys.argv[2]
if str(payload.get("status") or "").lower() != "ok":
raise SystemExit(f"unexpected health status: {payload.get('status')!r}")
lan_routing = payload.get("lanRouting") or {}
if not isinstance(lan_routing, dict):
raise SystemExit("lanRouting missing")
if lan_routing.get("cutvideoBaseUrl") != expected_cutvideo:
raise SystemExit(f"unexpected cutvideoBaseUrl: {lan_routing.get('cutvideoBaseUrl')!r}")
if lan_routing.get("cutvideoRouteMode") != "fnos_tunnel":
raise SystemExit(f"unexpected cutvideoRouteMode: {lan_routing.get('cutvideoRouteMode')!r}")
print("healthz ok")
PY
echo "[4/6] check auto-session"
curl_fetch -X POST "$BACKEND_URL/v2/auth/auto-session" \
-H 'content-type: application/json' \
-d '{}' >"$session_file"
python3 - "$session_file" "$token_file" <<'PY'
import json
import pathlib
import sys
payload = json.loads(pathlib.Path(sys.argv[1]).read_text())
token = str(payload.get("token") or "")
mode = str(payload.get("mode") or "")
username = str((payload.get("account") or {}).get("username") or "")
if not token:
raise SystemExit("auto-session did not return token")
if mode != "auto":
raise SystemExit(f"unexpected mode: {mode!r}")
if not username:
raise SystemExit("auto-session returned empty username")
pathlib.Path(sys.argv[2]).write_text(token, encoding="utf-8")
print(f"auto-session ok: {username}")
PY
token="$(cat "$token_file")"
echo "[5/6] check integrations health"
curl_fetch "$BACKEND_URL/v2/integrations/health" \
-H "Authorization: Bearer $token" >"$integrations_file"
python3 - "$integrations_file" "$CUTVIDEO_URL" <<'PY'
import json
import pathlib
import sys
payload = json.loads(pathlib.Path(sys.argv[1]).read_text())
expected_cutvideo = sys.argv[2]
cutvideo = payload.get("cutvideo") or {}
if cutvideo.get("base_url") != expected_cutvideo:
raise SystemExit(f"unexpected cutvideo base_url: {cutvideo.get('base_url')!r}")
if not cutvideo.get("reachable"):
raise SystemExit("cutvideo is not reachable")
if not cutvideo.get("supports_uploads"):
raise SystemExit("cutvideo uploads are not available")
print("integrations ok")
PY
echo "[6/6] check fnOS tunnel endpoints"
curl_fetch "$CUTVIDEO_URL/api/bootstrap" >"$bootstrap_file"
curl_fetch "$COMPAT_URL/" >"$compat_file"
python3 - "$bootstrap_file" <<'PY'
import json
import pathlib
import sys
payload = json.loads(pathlib.Path(sys.argv[1]).read_text())
if not payload:
raise SystemExit("empty cutvideo bootstrap payload")
print("cutvideo bootstrap ok")
PY
echo "compat ok"
echo "fnOS lan smoke passed:"
echo " web: $WEB_URL/"
echo " collector: $BACKEND_URL"
echo " cutvideo: $CUTVIDEO_URL"
echo " compat: $COMPAT_URL"

View File

@@ -228,6 +228,42 @@ class ProductionBaselineTests(unittest.TestCase):
self.assertEqual(payload["account"]["username"], ctx["username"])
self.assertEqual(payload["mode"], "auto")
def test_repo_contains_fnos_lan_stack_deploy_entrypoint(self) -> None:
script_path = ROOT / "scripts" / "deploy_fnos_storyforge_lan_stack.sh"
self.assertTrue(script_path.exists(), str(script_path))
content = script_path.read_text(encoding="utf-8")
self.assertIn("deploy_fnos_cutvideo_tunnel.sh", content)
self.assertIn("deploy_fnos_storyforge_collector.sh", content)
self.assertIn("deploy_fnos_storyforge_web.sh", content)
def test_repo_contains_fnos_lan_smoke_script(self) -> None:
script_path = ROOT / "scripts" / "smoke_fnos_storyforge_lan.sh"
self.assertTrue(script_path.exists(), str(script_path))
content = script_path.read_text(encoding="utf-8")
for expected in [
"/healthz",
"/v2/auth/auto-session",
"/v2/integrations/health",
"/api/bootstrap",
"19181",
]:
self.assertIn(expected, content)
def test_healthz_exposes_lan_routing_summary(self) -> None:
response = self.client.get("/healthz")
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()
self.assertIn("lanRouting", payload)
self.assertIn("cutvideoRouteMode", payload["lanRouting"])
self.assertIn("cutvideoBaseUrl", payload["lanRouting"])
def test_collector_deploy_script_exposes_health_retry_controls(self) -> None:
script_path = ROOT / "scripts" / "deploy_fnos_storyforge_collector.sh"
content = script_path.read_text(encoding="utf-8")
self.assertIn("COLLECTOR_HEALTH_RETRY_ATTEMPTS", content)
self.assertIn("COLLECTOR_HEALTH_RETRY_SLEEP_SEC", content)
self.assertNotIn("$(_attempt)", content)
def test_database_uses_wal_and_busy_timeout(self) -> None:
conn = self.core.db.connect()
try:

View File

@@ -135,7 +135,7 @@ const INTEGRATION_META = {
},
cutvideo: {
label: "自动剪辑",
hint: "Windows cutvideo",
hint: "cutvideo 直连",
impacts: ["实拍剪辑"]
},
huobao: {
@@ -2219,6 +2219,7 @@ function getIntegrationDetail(key) {
error: String(raw?.error || ""),
url: String(raw?.url || raw?.base_url || ""),
baseUrl: String(raw?.base_url || ""),
routeMode: String(raw?.route_mode || ""),
supportsUploads: raw?.supports_uploads !== undefined ? Boolean(raw?.supports_uploads) : true,
uploadStatusCode: Number(raw?.upload_status_code || 0),
uploadError: String(raw?.upload_error || ""),
@@ -2226,6 +2227,24 @@ function getIntegrationDetail(key) {
};
}
function isFnosTunnelCutvideo(detail) {
if (!detail || detail.key !== "cutvideo") return false;
if (detail.routeMode) return detail.routeMode === "fnos_tunnel";
const baseUrl = String(detail.baseUrl || detail.url || "");
return /:19186(?:\/|$)/.test(baseUrl);
}
function getCutvideoIntegrationHint(detail) {
if (isFnosTunnelCutvideo(detail)) {
return "fnOS NAS 隧道入口";
}
return "Windows 直连";
}
function getCutvideoIntegrationUrlLabel(detail) {
return isFnosTunnelCutvideo(detail) ? "fnOS NAS 隧道入口" : "Windows 直连";
}
function getIntegrationStatus(detail) {
if (!detail.available) {
return { tone: "blue", summary: "未拉取" };
@@ -2285,6 +2304,7 @@ function getIntegrationCards() {
const detail = getIntegrationDetail(key);
const status = getIntegrationStatus(detail);
const meta = INTEGRATION_META[key] || { label: key, hint: key, impacts: [] };
const metaHint = key === "cutvideo" ? getCutvideoIntegrationHint(detail) : meta.hint;
let note = "尚未获取健康检查数据";
if (detail.available) {
if (detail.reachable) {
@@ -2292,6 +2312,8 @@ function getIntegrationCards() {
note = detail.uploadStatusCode
? `主服务在线,但 /api/uploads 返回 HTTP ${detail.uploadStatusCode}`
: (detail.uploadError ? brief(detail.uploadError, 72) : "主服务在线,但缺少上传接口");
} else if (key === "cutvideo" && isFnosTunnelCutvideo(detail)) {
note = "当前走 fnOS NAS 隧道,不是 Windows 直连 cutvideo";
} else {
note = detail.statusCode
? `健康探测返回 HTTP ${detail.statusCode}`
@@ -2333,9 +2355,17 @@ function getIntegrationCards() {
: "当前还没有你的录制源";
actions = `<span class="tag clickable-tag" data-action="open-live-recorder">录制控制</span>`;
}
if (key === "cutvideo") {
extra = isFnosTunnelCutvideo(detail)
? `当前通过 fnOS NAS 隧道访问 ${detail.baseUrl || detail.url || "cutvideo"}`
: `当前直连 ${detail.baseUrl || detail.url || "cutvideo"}`;
}
return {
key,
meta,
meta: {
...meta,
hint: metaHint
},
detail,
status,
note,
@@ -3109,7 +3139,7 @@ function renderIntegrationOverviewPanel(options = {}) {
</div>
<div class="integration-note">${escapeHtml(item.note)}</div>
${item.extra ? `<div class="integration-note">${escapeHtml(item.extra)}</div>` : ""}
<div class="integration-url">${escapeHtml(item.key === "live_recorder" ? "仅通过当前租户的后端代理访问" : (item.detail.url || item.detail.baseUrl || "未提供探测地址"))}</div>
<div class="integration-url">${escapeHtml(item.key === "live_recorder" ? "仅通过当前租户的后端代理访问" : (item.key === "cutvideo" ? `${getCutvideoIntegrationUrlLabel(item.detail)}${item.detail.url || item.detail.baseUrl || "未提供探测地址"}` : (item.detail.url || item.detail.baseUrl || "未提供探测地址")))}</div>
${item.actions ? `<div class="task-meta integration-highlights" style="margin-top:12px;">${item.actions}</div>` : ""}
</div>
`).join("")}