diff --git a/README.md b/README.md index e96836d..9927dca 100644 --- a/README.md +++ b/README.md @@ -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 校验整条链路。 diff --git a/collector-service/app/core_main.py b/collector-service/app/core_main.py index f6f1325..33dccce 100644 --- a/collector-service/app/core_main.py +++ b/collector-service/app/core_main.py @@ -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, diff --git a/docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md b/docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md new file mode 100644 index 0000000..c8bd4a3 --- /dev/null +++ b/docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md @@ -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,再看详细日志 diff --git a/docs/LAN_E2E_GUIDE_2026-03-18.md b/docs/LAN_E2E_GUIDE_2026-03-18.md index 775d935..1d78f81 100644 --- a/docs/LAN_E2E_GUIDE_2026-03-18.md +++ b/docs/LAN_E2E_GUIDE_2026-03-18.md @@ -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://: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 视频链路验证 diff --git a/docs/superpowers/plans/2026-03-27-fnos-lan-delivery-stabilization.md b/docs/superpowers/plans/2026-03-27-fnos-lan-delivery-stabilization.md new file mode 100644 index 0000000..8c684f2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-fnos-lan-delivery-stabilization.md @@ -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://: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 smoke:Task 2 覆盖 +- 前后端状态收口:Task 3 覆盖 +- 文档与运维统一:Task 4 覆盖 + +### Placeholder scan + +- 没有保留 TBD / TODO / “后续补” +- 每个任务都给了明确文件和验证命令 + +### Type consistency + +- 统一使用 `deploy_fnos_storyforge_lan_stack.sh` +- 统一使用 `smoke_fnos_storyforge_lan.sh` +- `healthz` 新字段统一命名为 `lanRouting` diff --git a/scripts/deploy_fnos_storyforge_collector.sh b/scripts/deploy_fnos_storyforge_collector.sh index 0401457..dff67a4 100755 --- a/scripts/deploy_fnos_storyforge_collector.sh +++ b/scripts/deploy_fnos_storyforge_collector.sh @@ -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" diff --git a/scripts/deploy_fnos_storyforge_lan_stack.sh b/scripts/deploy_fnos_storyforge_lan_stack.sh new file mode 100755 index 0000000..c1ee7f1 --- /dev/null +++ b/scripts/deploy_fnos_storyforge_lan_stack.sh @@ -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" diff --git a/scripts/smoke_fnos_storyforge_lan.sh b/scripts/smoke_fnos_storyforge_lan.sh new file mode 100755 index 0000000..1fa600e --- /dev/null +++ b/scripts/smoke_fnos_storyforge_lan.sh @@ -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" diff --git a/tests/test_production_baseline.py b/tests/test_production_baseline.py index 429e373..7df5565 100644 --- a/tests/test_production_baseline.py +++ b/tests/test_production_baseline.py @@ -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: diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 5e4ed8a..9b2b484 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -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 = `录制控制`; } + 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 = {}) {
${escapeHtml(item.note)}
${item.extra ? `
${escapeHtml(item.extra)}
` : ""} -
${escapeHtml(item.key === "live_recorder" ? "仅通过当前租户的后端代理访问" : (item.detail.url || item.detail.baseUrl || "未提供探测地址"))}
+
${escapeHtml(item.key === "live_recorder" ? "仅通过当前租户的后端代理访问" : (item.key === "cutvideo" ? `${getCutvideoIntegrationUrlLabel(item.detail)}:${item.detail.url || item.detail.baseUrl || "未提供探测地址"}` : (item.detail.url || item.detail.baseUrl || "未提供探测地址")))}
${item.actions ? `
${item.actions}
` : ""} `).join("")}