feat: migrate orchestration to n8n and validate lan mvp
This commit is contained in:
24
.env.example
24
.env.example
@@ -2,15 +2,25 @@ DEFAULT_EXTERNAL_BASE_URL=http://test.hyzq.net:8081
|
||||
LOCAL_OPENAI_BASE_URL=http://127.0.0.1:8317/v1
|
||||
LOCAL_OPENAI_MODEL=GLM-5
|
||||
LOCAL_OPENAI_API_KEY=
|
||||
FASTGPT_BASE_URL=http://127.0.0.1:3000
|
||||
FASTGPT_DATASET_API_KEY=
|
||||
N8N_BASE_URL=http://127.0.0.1:5670
|
||||
N8N_ANALYSIS_WEBHOOK_PATH=/webhook/storyforge-analysis
|
||||
N8N_REAL_CUT_WEBHOOK_PATH=/webhook/storyforge-real-cut
|
||||
N8N_AI_VIDEO_WEBHOOK_PATH=/webhook/storyforge-ai-video
|
||||
ORCHESTRATOR_SHARED_SECRET=storyforge-local-secret
|
||||
CUTVIDEO_BASE_URL=
|
||||
CUTVIDEO_API_KEY=
|
||||
CUTVIDEO_BASE_CONFIG=example.job.yaml
|
||||
CUTVIDEO_POLL_INTERVAL_SEC=10
|
||||
CUTVIDEO_MAX_WAIT_SEC=1800
|
||||
HUOBAO_BASE_URL=http://127.0.0.1:5678
|
||||
HUOBAO_POLL_INTERVAL_SEC=10
|
||||
HUOBAO_MAX_WAIT_SEC=900
|
||||
YTDLP_BIN=yt-dlp
|
||||
FFMPEG_BIN=ffmpeg
|
||||
WHISPER_BIN=
|
||||
WHISPER_MODEL=./data/collector/models/ggml-base.en.bin
|
||||
POSTGRES_DB=fastgpt
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
N8N_IMAGE=docker.n8n.io/n8nio/n8n:latest
|
||||
WEBHOOK_URL=http://127.0.0.1:5670/
|
||||
GENERIC_TIMEZONE=Asia/Shanghai
|
||||
TZ=Asia/Shanghai
|
||||
CLIPROXY_IMAGE=storyforge/cli-proxy-api:patched
|
||||
|
||||
44
README.md
44
README.md
@@ -5,36 +5,68 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
|
||||
## 目录
|
||||
|
||||
- `android-app/`:StoryForge Android 客户端
|
||||
- `collector-service/`:FastAPI 后端,提供登录、审批、素材导入、知识库、智能体和 OTA
|
||||
- `docker-compose.yml`:本地 FastGPT / collector / 基础依赖编排
|
||||
- `collector-service/`:FastAPI 后端,负责用户体系、项目、Agent、任务、内容分析和对外能力接入
|
||||
- `n8n/`:工作流导出文件,作为流程编排中枢
|
||||
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
|
||||
- `Common/`:项目约束和架构说明
|
||||
- `data/collector/`:SQLite、任务文件、下载产物
|
||||
- `docs/`:审计、实施计划、联调说明、当前 MVP 状态
|
||||
|
||||
## Android
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge/android-app
|
||||
cd /Users/kris/code/StoryForge-gitea/android-app
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
## Collector Service
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge/collector-service
|
||||
cd /Users/kris/code/StoryForge-gitea/collector-service
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8081 --reload
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
默认会启动:
|
||||
|
||||
- `collector-service`:`http://127.0.0.1:8081`
|
||||
- `n8n`:`http://127.0.0.1:5670`
|
||||
- `cli-proxy-api`:`http://127.0.0.1:8317`
|
||||
|
||||
默认会创建最高权限账号:
|
||||
|
||||
- `kris`
|
||||
- `Asd123456.`
|
||||
|
||||
## 当前架构
|
||||
|
||||
- `collector-service` 负责:
|
||||
- 用户账号、多项目、多 Agent、多任务、多内容源数据边界
|
||||
- 调用下载器、本地 ASR、本机 OpenAI 兼容模型
|
||||
- 调用 Windows `cutvideo` 和 `huobao-drama`
|
||||
- 持久化任务、分镜、分析结果、事件日志
|
||||
- `n8n` 负责:
|
||||
- 触发 `analysis_pipeline`
|
||||
- 触发 `real_cut_pipeline`
|
||||
- 触发 `ai_video_pipeline`
|
||||
- FastGPT 已从主流程设计中移除,不再作为运行时依赖
|
||||
|
||||
## 说明
|
||||
|
||||
- 新注册账号默认 `pending`
|
||||
- 主管理员审批后才可使用核心业务接口
|
||||
- 素材入口支持文字、视频链接、视频上传
|
||||
- 可选对接本机 OpenAI 兼容模型服务和 FastGPT 数据集 API
|
||||
- 支持 `user -> project -> knowledge base / assistant(agent) / job / content source` 的多租户边界
|
||||
- 素材入口支持文字、视频链接、视频上传;内容源账号通过 `content_sources` 建模持久化
|
||||
- `cutvideo` 继续运行在 Windows 机器,本系统通过 API 调度
|
||||
- `huobao-drama` 继续作为 AI 生成视频主链的核心引擎
|
||||
- 详细审计、阶段计划和联调步骤见 `docs/`
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
@Serializable
|
||||
data class RegisterAccountRequest(
|
||||
@@ -66,12 +70,22 @@ data class PreferredModelRequest(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeBaseDto(
|
||||
data class ProjectDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val fastgpt_dataset_id: String? = null,
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeBaseDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val project_id: String = "",
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val sync_status: String = "pending",
|
||||
val document_count: Int = 0,
|
||||
val linked_assistant_count: Int = 0,
|
||||
@@ -82,6 +96,7 @@ data class KnowledgeBaseDto(
|
||||
@Serializable
|
||||
data class KnowledgeBaseCreateRequest(
|
||||
val name: String,
|
||||
val project_id: String = "",
|
||||
val description: String = ""
|
||||
)
|
||||
|
||||
@@ -89,12 +104,13 @@ data class KnowledgeBaseCreateRequest(
|
||||
data class AssistantDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val project_id: String = "",
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val system_prompt: String = "",
|
||||
val generation_goal: String = "",
|
||||
val knowledge_base_ids: List<String> = emptyList(),
|
||||
val fastgpt_app_key: String = "",
|
||||
val config: JsonObject = buildJsonObject { },
|
||||
val model_profile_id: String = "",
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
@@ -107,7 +123,7 @@ data class AssistantCreateRequest(
|
||||
val system_prompt: String = "",
|
||||
val generation_goal: String = "",
|
||||
val knowledge_base_ids: List<String> = emptyList(),
|
||||
val fastgpt_app_key: String = "",
|
||||
val project_id: String = "",
|
||||
val model_profile_id: String = ""
|
||||
)
|
||||
|
||||
@@ -118,7 +134,7 @@ data class AssistantUpdateRequest(
|
||||
val system_prompt: String? = null,
|
||||
val generation_goal: String? = null,
|
||||
val knowledge_base_ids: List<String>? = null,
|
||||
val fastgpt_app_key: String? = null,
|
||||
val project_id: String? = null,
|
||||
val model_profile_id: String? = null
|
||||
)
|
||||
|
||||
@@ -126,6 +142,7 @@ data class AssistantUpdateRequest(
|
||||
data class ExploreVideoLinkRequest(
|
||||
val video_url: String,
|
||||
val title: String? = null,
|
||||
val project_id: String? = null,
|
||||
val knowledge_base_id: String? = null,
|
||||
val assistant_id: String? = null,
|
||||
val analysis_model_profile_id: String? = null,
|
||||
@@ -136,6 +153,7 @@ data class ExploreVideoLinkRequest(
|
||||
data class ExploreTextRequest(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val project_id: String? = null,
|
||||
val knowledge_base_id: String? = null,
|
||||
val assistant_id: String? = null,
|
||||
val analysis_model_profile_id: String? = null
|
||||
@@ -145,19 +163,26 @@ data class ExploreTextRequest(
|
||||
data class JobDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val project_id: String = "",
|
||||
val assistant_id: String? = null,
|
||||
val knowledge_base_id: String,
|
||||
val content_source_id: String = "",
|
||||
val source_type: String,
|
||||
val line_type: String = "analysis",
|
||||
val workflow_key: String = "",
|
||||
val orchestrator: String = "n8n",
|
||||
val provider_name: String = "",
|
||||
val provider_task_id: String = "",
|
||||
val source_url: String? = null,
|
||||
val title: String,
|
||||
val language: String,
|
||||
val status: String,
|
||||
val transcript_text: String = "",
|
||||
val style_summary: String = "",
|
||||
val fastgpt_collection_id: String = "",
|
||||
val upload_status: String = "pending",
|
||||
val error: String = "",
|
||||
val artifacts: Map<String, String> = emptyMap(),
|
||||
val artifacts: JsonObject = buildJsonObject { },
|
||||
val result: JsonObject = buildJsonObject { },
|
||||
val analysis_model_profile_id: String = "",
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
@@ -173,7 +198,9 @@ data class KnowledgeDocumentDto(
|
||||
val transcript_text: String = "",
|
||||
val style_summary: String = "",
|
||||
val combined_text: String = "",
|
||||
val fastgpt_collection_id: String = "",
|
||||
val analysis: JsonObject = buildJsonObject { },
|
||||
val storyboards: JsonArray = buildJsonArray { },
|
||||
val source_artifacts: JsonObject = buildJsonObject { },
|
||||
val analysis_model_profile_id: String = "",
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
@@ -200,6 +227,7 @@ data class GenerateCopyResponseDto(
|
||||
@Serializable
|
||||
data class DashboardDto(
|
||||
val account: AccountDto,
|
||||
val projects: List<ProjectDto> = emptyList(),
|
||||
val knowledge_bases: List<KnowledgeBaseDto> = emptyList(),
|
||||
val assistants: List<AssistantDto> = emptyList(),
|
||||
val recent_jobs: List<JobDto> = emptyList(),
|
||||
|
||||
@@ -48,6 +48,18 @@ class Database:
|
||||
with self.session() as conn:
|
||||
conn.execute(sql, params)
|
||||
|
||||
def table_exists(self, name: str) -> bool:
|
||||
row = self.fetch_one(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(name,),
|
||||
)
|
||||
return bool(row)
|
||||
|
||||
def column_exists(self, table: str, column: str) -> bool:
|
||||
with self.session() as conn:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
return any(row["name"] == column for row in rows)
|
||||
|
||||
def init_schema(self) -> None:
|
||||
schema = """
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
@@ -90,10 +102,10 @@ class Database:
|
||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
fastgpt_dataset_id TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||
sync_status TEXT NOT NULL DEFAULT 'ready',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
@@ -108,7 +120,9 @@ class Database:
|
||||
transcript_text TEXT NOT NULL DEFAULT '',
|
||||
style_summary TEXT NOT NULL DEFAULT '',
|
||||
combined_text TEXT NOT NULL DEFAULT '',
|
||||
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
|
||||
analysis_json TEXT NOT NULL DEFAULT '{}',
|
||||
storyboard_json TEXT NOT NULL DEFAULT '[]',
|
||||
source_artifact_json TEXT NOT NULL DEFAULT '{}',
|
||||
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
@@ -118,11 +132,12 @@ class Database:
|
||||
CREATE TABLE IF NOT EXISTS assistants (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
system_prompt TEXT NOT NULL DEFAULT '',
|
||||
generation_goal TEXT NOT NULL DEFAULT '',
|
||||
fastgpt_app_key TEXT NOT NULL DEFAULT '',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
model_profile_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
@@ -140,19 +155,26 @@ class Database:
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
assistant_id TEXT,
|
||||
knowledge_base_id TEXT NOT NULL,
|
||||
content_source_id TEXT,
|
||||
source_type TEXT NOT NULL,
|
||||
line_type TEXT NOT NULL DEFAULT 'analysis',
|
||||
workflow_key TEXT NOT NULL DEFAULT '',
|
||||
orchestrator TEXT NOT NULL DEFAULT 'n8n',
|
||||
provider_name TEXT NOT NULL DEFAULT '',
|
||||
provider_task_id TEXT NOT NULL DEFAULT '',
|
||||
source_url TEXT,
|
||||
title TEXT NOT NULL,
|
||||
language TEXT NOT NULL DEFAULT 'auto',
|
||||
status TEXT NOT NULL,
|
||||
transcript_text TEXT NOT NULL DEFAULT '',
|
||||
style_summary TEXT NOT NULL DEFAULT '',
|
||||
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
|
||||
upload_status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
artifacts_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
@@ -161,6 +183,42 @@ class Database:
|
||||
FOREIGN KEY(knowledge_base_id) REFERENCES knowledge_bases(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
source_kind TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
handle TEXT NOT NULL DEFAULT '',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
local_path TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_updates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
@@ -179,3 +237,102 @@ class Database:
|
||||
"""
|
||||
with self.session() as conn:
|
||||
conn.executescript(schema)
|
||||
self.migrate_schema()
|
||||
|
||||
def migrate_schema(self) -> None:
|
||||
table_columns: dict[str, dict[str, str]] = {
|
||||
"knowledge_bases": {
|
||||
"project_id": "TEXT",
|
||||
},
|
||||
"knowledge_documents": {
|
||||
"analysis_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
"storyboard_json": "TEXT NOT NULL DEFAULT '[]'",
|
||||
"source_artifact_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
},
|
||||
"assistants": {
|
||||
"project_id": "TEXT",
|
||||
"config_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
},
|
||||
"jobs": {
|
||||
"project_id": "TEXT",
|
||||
"content_source_id": "TEXT",
|
||||
"line_type": "TEXT NOT NULL DEFAULT 'analysis'",
|
||||
"workflow_key": "TEXT NOT NULL DEFAULT ''",
|
||||
"orchestrator": "TEXT NOT NULL DEFAULT 'n8n'",
|
||||
"provider_name": "TEXT NOT NULL DEFAULT ''",
|
||||
"provider_task_id": "TEXT NOT NULL DEFAULT ''",
|
||||
"result_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
},
|
||||
}
|
||||
|
||||
for table, columns in table_columns.items():
|
||||
if not self.table_exists(table):
|
||||
continue
|
||||
for column, definition in columns.items():
|
||||
if self.column_exists(table, column):
|
||||
continue
|
||||
self.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
|
||||
self.ensure_default_projects()
|
||||
|
||||
def ensure_default_projects(self) -> None:
|
||||
if not self.table_exists("projects"):
|
||||
return
|
||||
|
||||
accounts = self.fetch_all("SELECT id, username FROM accounts ORDER BY created_at ASC")
|
||||
for account in accounts:
|
||||
project = self.fetch_one(
|
||||
"SELECT * FROM projects WHERE user_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||
(account["id"],),
|
||||
)
|
||||
if not project:
|
||||
project_id = f"proj_{account['id']}"
|
||||
now = utc_now()
|
||||
self.execute(
|
||||
"""
|
||||
INSERT INTO projects (id, user_id, name, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
account["id"],
|
||||
f"{account['username']} 默认项目",
|
||||
"系统自动创建的默认项目",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
project = self.fetch_one("SELECT * FROM projects WHERE id = ?", (project_id,))
|
||||
|
||||
if not project:
|
||||
continue
|
||||
|
||||
if self.column_exists("knowledge_bases", "project_id"):
|
||||
self.execute(
|
||||
"""
|
||||
UPDATE knowledge_bases
|
||||
SET project_id = ?
|
||||
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
|
||||
""",
|
||||
(project["id"], account["id"]),
|
||||
)
|
||||
|
||||
if self.column_exists("assistants", "project_id"):
|
||||
self.execute(
|
||||
"""
|
||||
UPDATE assistants
|
||||
SET project_id = ?
|
||||
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
|
||||
""",
|
||||
(project["id"], account["id"]),
|
||||
)
|
||||
|
||||
if self.column_exists("jobs", "project_id"):
|
||||
self.execute(
|
||||
"""
|
||||
UPDATE jobs
|
||||
SET project_id = ?
|
||||
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
|
||||
""",
|
||||
(project["id"], account["id"]),
|
||||
)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class FastGPTClient:
|
||||
def __init__(self, *, base_url: str, dataset_api_key: str, timeout: float = 60.0) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.dataset_api_key = dataset_api_key.strip()
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.base_url and self.dataset_api_key)
|
||||
|
||||
async def ensure_dataset(self, name: str, intro: str = "") -> dict[str, Any] | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
payload = {"name": name, "intro": intro}
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/core/dataset/create",
|
||||
headers={"Authorization": f"Bearer {self.dataset_api_key}"},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json().get("data") or response.json()
|
||||
|
||||
async def add_text_document(self, dataset_id: str, name: str, text: str) -> dict[str, Any] | None:
|
||||
if not self.enabled or not dataset_id.strip():
|
||||
return None
|
||||
payload = {
|
||||
"datasetId": dataset_id,
|
||||
"type": "text",
|
||||
"name": name,
|
||||
"trainingType": "chunk",
|
||||
"text": text,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/core/dataset/collection/create/text",
|
||||
headers={"Authorization": f"Bearer {self.dataset_api_key}"},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json().get("data") or response.json()
|
||||
162
collector-service/app/integrations.py
Normal file
162
collector-service/app/integrations.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _join_url(base_url: str, path: str) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
return f"{base}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _unwrap_response(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
return {"value": payload}
|
||||
if payload.get("success") is True and "data" in payload:
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return {"value": data}
|
||||
return payload
|
||||
|
||||
|
||||
class N8NClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
workflow_paths: dict[str, str],
|
||||
shared_secret: str = "",
|
||||
timeout: float = 60.0,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.workflow_paths = workflow_paths
|
||||
self.shared_secret = shared_secret.strip()
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
async def trigger(self, workflow_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
workflow_path = self.workflow_paths.get(workflow_key, "").strip()
|
||||
if not workflow_path:
|
||||
raise ValueError(f"workflow path not configured for {workflow_key}")
|
||||
try:
|
||||
workflow_path = workflow_path.format(**payload)
|
||||
except KeyError:
|
||||
pass
|
||||
headers: dict[str, str] = {}
|
||||
if self.shared_secret:
|
||||
headers["X-Orchestrator-Secret"] = self.shared_secret
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
_join_url(self.base_url, workflow_path),
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return {"triggered": True}
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
|
||||
class CutVideoClient:
|
||||
def __init__(self, *, base_url: str, api_key: str = "", timeout: float = 120.0) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key.strip()
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
return headers
|
||||
|
||||
async def submit_job(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
_join_url(self.base_url, "/api/jobs"),
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
_join_url(self.base_url, f"/api/tasks/{task_id}"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def get_run(self, run_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
_join_url(self.base_url, f"/api/runs/{run_id}"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
|
||||
class HuobaoDramaClient:
|
||||
def __init__(self, *, base_url: str, timeout: float = 180.0) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
async def create_drama(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
_join_url(self.base_url, "/api/v1/dramas"),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def generate_image(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
_join_url(self.base_url, "/api/v1/images"),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def get_image(self, image_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
_join_url(self.base_url, f"/api/v1/images/{image_id}"),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def generate_video(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
_join_url(self.base_url, "/api/v1/videos"),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def get_video(self, video_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
_join_url(self.base_url, f"/api/v1/videos/{video_id}"),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,56 +1,30 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:6
|
||||
container_name: storyforge-mongo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- ./data/mongo:/data/db
|
||||
|
||||
vectorDB:
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: storyforge-pgvector
|
||||
n8n:
|
||||
image: ${N8N_IMAGE:-docker.n8n.io/n8nio/n8n:latest}
|
||||
container_name: storyforge-n8n
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-fastgpt}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
N8N_HOST: ${N8N_HOST:-0.0.0.0}
|
||||
N8N_PORT: 5678
|
||||
N8N_PROTOCOL: ${N8N_PROTOCOL:-http}
|
||||
WEBHOOK_URL: ${WEBHOOK_URL:-http://127.0.0.1:5670/}
|
||||
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Asia/Shanghai}
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false}
|
||||
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: ${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-false}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "5670:5678"
|
||||
volumes:
|
||||
- ./data/pg:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: storyforge-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-02-07T23-21-09Z
|
||||
container_name: storyforge-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- ./data/minio:/data
|
||||
- ./data/n8n:/home/node/.n8n
|
||||
- ./n8n:/workspace/n8n:ro
|
||||
|
||||
collector:
|
||||
build:
|
||||
context: ./collector-service
|
||||
container_name: storyforge-collector
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- n8n
|
||||
environment:
|
||||
DATA_DIR: /data/collector
|
||||
DATABASE_PATH: /data/collector/storyforge.db
|
||||
@@ -58,40 +32,29 @@ services:
|
||||
LOCAL_OPENAI_BASE_URL: ${LOCAL_OPENAI_BASE_URL:-http://host.docker.internal:8317/v1}
|
||||
LOCAL_OPENAI_MODEL: ${LOCAL_OPENAI_MODEL:-GLM-5}
|
||||
LOCAL_OPENAI_API_KEY: ${LOCAL_OPENAI_API_KEY:-}
|
||||
FASTGPT_BASE_URL: ${FASTGPT_BASE_URL:-http://host.docker.internal:3000}
|
||||
FASTGPT_DATASET_API_KEY: ${FASTGPT_DATASET_API_KEY:-}
|
||||
N8N_BASE_URL: ${N8N_BASE_URL:-http://n8n:5678}
|
||||
N8N_ANALYSIS_WEBHOOK_PATH: ${N8N_ANALYSIS_WEBHOOK_PATH:-/webhook/storyforge-analysis}
|
||||
N8N_REAL_CUT_WEBHOOK_PATH: ${N8N_REAL_CUT_WEBHOOK_PATH:-/webhook/storyforge-real-cut}
|
||||
N8N_AI_VIDEO_WEBHOOK_PATH: ${N8N_AI_VIDEO_WEBHOOK_PATH:-/webhook/storyforge-ai-video}
|
||||
ORCHESTRATOR_SHARED_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret}
|
||||
CUTVIDEO_BASE_URL: ${CUTVIDEO_BASE_URL:-}
|
||||
CUTVIDEO_API_KEY: ${CUTVIDEO_API_KEY:-}
|
||||
CUTVIDEO_BASE_CONFIG: ${CUTVIDEO_BASE_CONFIG:-example.job.yaml}
|
||||
CUTVIDEO_POLL_INTERVAL_SEC: ${CUTVIDEO_POLL_INTERVAL_SEC:-10}
|
||||
CUTVIDEO_MAX_WAIT_SEC: ${CUTVIDEO_MAX_WAIT_SEC:-1800}
|
||||
HUOBAO_BASE_URL: ${HUOBAO_BASE_URL:-http://host.docker.internal:5678}
|
||||
YTDLP_BIN: ${YTDLP_BIN:-yt-dlp}
|
||||
FFMPEG_BIN: ${FFMPEG_BIN:-ffmpeg}
|
||||
WHISPER_BIN: ${WHISPER_BIN:-}
|
||||
WHISPER_MODEL: ${WHISPER_MODEL:-/data/collector/models/ggml-base.en.bin}
|
||||
HUOBAO_POLL_INTERVAL_SEC: ${HUOBAO_POLL_INTERVAL_SEC:-10}
|
||||
HUOBAO_MAX_WAIT_SEC: ${HUOBAO_MAX_WAIT_SEC:-900}
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./data/collector:/data/collector
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8081
|
||||
|
||||
fastgpt:
|
||||
image: ghcr.io/labring/fastgpt:latest
|
||||
container_name: storyforge-fastgpt
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- mongo
|
||||
- vectorDB
|
||||
- redis
|
||||
- minio
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
sandbox:
|
||||
image: ghcr.io/labring/fastgpt-sandbox:latest
|
||||
container_name: storyforge-sandbox
|
||||
restart: unless-stopped
|
||||
|
||||
fastgpt-plugin:
|
||||
image: ghcr.io/labring/fastgpt-plugin:latest
|
||||
container_name: storyforge-fastgpt-plugin
|
||||
restart: unless-stopped
|
||||
|
||||
cli-proxy-api:
|
||||
image: ${CLIPROXY_IMAGE:-storyforge/cli-proxy-api:patched}
|
||||
container_name: storyforge-cliproxyapi
|
||||
|
||||
134
docs/AUDIT_2026-03-18.md
Normal file
134
docs/AUDIT_2026-03-18.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# StoryForge 现状审计
|
||||
|
||||
日期:2026-03-18
|
||||
|
||||
## 结论
|
||||
|
||||
当前应以 `/Users/kris/code/StoryForge-gitea` 作为主工作区继续推进,而不是 `/Users/kris/code/Fastgpt`。后者更像一次不完整的导入快照,前者才是可持续开发的真实仓库。
|
||||
|
||||
## 现有功能归位
|
||||
|
||||
### 1. `collector-service` 之前承担的功能
|
||||
|
||||
- 账号注册、登录、审批
|
||||
- 本地模型配置
|
||||
- 知识库、智能体、任务管理
|
||||
- 视频链接/上传视频/文本三类入口
|
||||
- 下载器、ffmpeg、whisper.cpp 风格的本地处理调用
|
||||
- Android OTA 查询/发布
|
||||
|
||||
### 2. FastGPT 实际承担的功能
|
||||
|
||||
- 仅承担“数据集/文档同步”的外部依赖角色
|
||||
- 代码痕迹集中在:
|
||||
- `collector-service/app/fastgpt.py`
|
||||
- `docker-compose.yml`
|
||||
- 若干 `fastgpt_*` 字段
|
||||
|
||||
结论:FastGPT 并不是业务内核,适合迁移后整体删除。
|
||||
|
||||
### 3. n8n 适合接管的功能
|
||||
|
||||
- 任务触发
|
||||
- 工作流分流
|
||||
- 外部能力编排入口
|
||||
- 任务执行顺序控制
|
||||
|
||||
不适合承载:
|
||||
|
||||
- 用户、项目、Agent、知识库、任务、历史记录的主数据
|
||||
- 业务状态唯一真相源
|
||||
|
||||
结论:应采用“业务状态在 `collector-service`,流程编排在 `n8n`”的分层。
|
||||
|
||||
## 多用户与数据边界
|
||||
|
||||
当前已明确采用:
|
||||
|
||||
- `accounts`
|
||||
- `projects`
|
||||
- `knowledge_bases`
|
||||
- `assistants`
|
||||
- `content_sources`
|
||||
- `jobs`
|
||||
- `job_events`
|
||||
|
||||
推荐模型:`user + project`。
|
||||
|
||||
理由:
|
||||
|
||||
- 只做 `user` 级隔离,会导致一个用户内部不同内容工作流难以再分边界
|
||||
- `project` 可以自然承接“一个创作者方向 / 一个客户 / 一个账号矩阵 / 一个内容实验”
|
||||
- `assistant`、`knowledge_base`、`job`、`content_source` 都能挂到 `project`,便于后续扩展协作空间
|
||||
|
||||
## 外部链路审计
|
||||
|
||||
### 1. 下载器
|
||||
|
||||
- 已存在,不需要重写
|
||||
- 现阶段通过 `yt-dlp` 命令集成
|
||||
|
||||
### 2. ASR
|
||||
|
||||
- 现有实现已部署,但入口未完全标准化
|
||||
- 当前后端支持 `ffmpeg + whisper.cpp` 风格接入
|
||||
- 若需要接现有常驻 ASR 服务,后续只需把 `transcribe_media()` 改成 HTTP/RPC 适配即可
|
||||
|
||||
### 3. Windows `cutvideo`
|
||||
|
||||
- 仓库:`/Users/kris/code/cutvideo`
|
||||
- 具备清晰 API:
|
||||
- `POST /api/jobs`
|
||||
- `GET /api/tasks/{task_id}`
|
||||
- `GET /api/runs/{run_id}`
|
||||
- 适合集成为“由 StoryForge 后端授权调用的局域网剪辑能力”
|
||||
|
||||
当前限制:
|
||||
|
||||
- 现有 `cutvideo` API 主要接受 `input_dir`
|
||||
- 对“用户上传实拍素材后直接推送到 Windows 机器”这一步,还缺一层文件转运方案
|
||||
|
||||
### 4. `huobao-drama`
|
||||
|
||||
- 旧改版位置:`/Users/kris/code/huobaoduanju/huobao-drama-master`
|
||||
- 最新 upstream:`/Users/kris/code/huobao-drama-upstream`
|
||||
- 旧改版主要多了一套 `ad_workflow` 方向,和当前 StoryForge MVP 不完全对齐
|
||||
- 最新版已具备:
|
||||
- `POST /api/v1/dramas`
|
||||
- `POST /api/v1/images`
|
||||
- `GET /api/v1/images/{id}`
|
||||
- `POST /api/v1/videos`
|
||||
- `GET /api/v1/videos/{id}`
|
||||
- `reference_mode=first_last`
|
||||
|
||||
本次真实联调里,旧改版为了兼容 `qnaigc` 需要补 4 个点:
|
||||
|
||||
- `pkg/image/openai_image_client.go`
|
||||
- `application/services/image_generation_service.go`
|
||||
- `pkg/video/openai_sora_client.go`
|
||||
- `application/services/video_generation_service.go`
|
||||
|
||||
核对结果:
|
||||
|
||||
- 以上 4 个文件与本机 upstream 同名文件在补丁前没有明显结构分叉
|
||||
- 当前差异基本就是 `qnaigc` 图片异步查询、Kling 视频 JSON 协议、结果 URL 解析、远程首尾帧 URL 保留这几处兼容逻辑
|
||||
|
||||
结论:这批补丁是可移植补丁,MVP 已在旧改版实例上验证通过;下一步应把同样补丁迁到最新版 `huobao-drama-upstream`,而不是继续在旧目录长期演进。
|
||||
|
||||
## 当前已完成迁移面
|
||||
|
||||
- FastGPT 运行时依赖已从 `collector-service` 主代码中剥离
|
||||
- 数据库已支持 `project/content_source/job_events`
|
||||
- `collector-service` 已增加:
|
||||
- `n8n` 触发
|
||||
- `cutvideo` 集成 client
|
||||
- `huobao-drama` 集成 client
|
||||
- 内部编排接口
|
||||
- `docker-compose.yml` 已改为 `collector + n8n + cli-proxy-api`
|
||||
- `n8n` 工作流导出文件已纳入仓库
|
||||
|
||||
## 当前主要风险
|
||||
|
||||
1. `cutvideo` 的素材传输还未完整闭环
|
||||
2. 本地 ASR 的“最终生产入口”仍需按你现有部署方式再绑一次
|
||||
3. `huobao-drama` 已在本机旧改版实例上跑通,但兼容补丁尚未迁到 upstream 仓库并形成正式提交
|
||||
97
docs/IMPLEMENTATION_PLAN_2026-03-18.md
Normal file
97
docs/IMPLEMENTATION_PLAN_2026-03-18.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# StoryForge 分阶段实施计划
|
||||
|
||||
日期:2026-03-18
|
||||
|
||||
## Phase 0: 审计与基线收拢
|
||||
|
||||
- 确认主工作区
|
||||
- 识别 FastGPT 真实职责
|
||||
- 识别多用户、多项目需要的主数据模型
|
||||
- 对比 `huobao-drama` 旧改版与 upstream
|
||||
- 审计 `cutvideo` 接口能力
|
||||
|
||||
状态:已完成
|
||||
|
||||
## Phase 1: 业务后端改造成主状态中心
|
||||
|
||||
- 引入 `projects`
|
||||
- 引入 `content_sources`
|
||||
- 引入 `job_events`
|
||||
- 让 `knowledge_bases / assistants / jobs` 全部 project 化
|
||||
- 去掉 `collector-service` 中的 FastGPT 运行时逻辑
|
||||
- 增加 `agents` 别名接口,统一 Agent 语义
|
||||
|
||||
状态:已完成首版
|
||||
|
||||
## Phase 2: n8n 接管流程编排
|
||||
|
||||
- 公共任务创建接口只负责建任务并触发工作流
|
||||
- `n8n` 负责分发:
|
||||
- `analysis_pipeline`
|
||||
- `real_cut_pipeline`
|
||||
- `ai_video_pipeline`
|
||||
- 业务步骤落在 `collector-service` 内部接口,保证状态统一入库
|
||||
|
||||
状态:已完成首版
|
||||
|
||||
## Phase 3: 内容分析主线 MVP
|
||||
|
||||
- 支持文本
|
||||
- 支持视频链接
|
||||
- 支持上传视频
|
||||
- 接下载器
|
||||
- 接本地 ASR
|
||||
- 接本地 LLM
|
||||
- 产出:
|
||||
- transcript
|
||||
- style_summary
|
||||
- analysis
|
||||
- rewrite
|
||||
- storyboards
|
||||
|
||||
状态:已完成首版
|
||||
|
||||
## Phase 4: 实拍自动剪辑主线 MVP
|
||||
|
||||
- 建立 `real_cut` 任务类型
|
||||
- 通过 `n8n -> collector -> cutvideo` 调度 Windows 机器
|
||||
- 记录 `task_id / run_id / 结果产物`
|
||||
|
||||
状态:已完成 API 级集成
|
||||
|
||||
待补:
|
||||
|
||||
- 用户上传素材到 Windows 侧的文件转运闭环
|
||||
|
||||
## Phase 5: AI 自动生成视频主线 MVP
|
||||
|
||||
- 建立 `ai_video` 任务类型
|
||||
- 从分析结果或直接 brief 生成分镜
|
||||
- 调 `huobao-drama`:
|
||||
- 创建 drama
|
||||
- 生成首帧
|
||||
- 生成尾帧
|
||||
- 基于首尾帧生成视频
|
||||
- 结果回写任务
|
||||
|
||||
状态:已完成 API 级集成
|
||||
|
||||
## Phase 6: 删除 FastGPT 运行依赖
|
||||
|
||||
- 删除代码依赖
|
||||
- 删除 compose 服务
|
||||
- 删除环境变量
|
||||
- 删除 README 说明
|
||||
|
||||
状态:已完成主仓库首版
|
||||
|
||||
## Phase 7: 联调与验证
|
||||
|
||||
- Python 语法检查
|
||||
- Compose 配置检查
|
||||
- `collector-service` 本地启动
|
||||
- `n8n` workflow 导入
|
||||
- Windows `cutvideo` 局域网调度
|
||||
- `huobao-drama` 本机调用
|
||||
|
||||
状态:进行中
|
||||
146
docs/LAN_E2E_GUIDE_2026-03-18.md
Normal file
146
docs/LAN_E2E_GUIDE_2026-03-18.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# StoryForge 本地 / 局域网联调说明
|
||||
|
||||
日期:2026-03-18
|
||||
|
||||
## 1. 准备 `.env`
|
||||
|
||||
复制:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
至少确认这些变量:
|
||||
|
||||
- `N8N_BASE_URL=http://127.0.0.1:5670`
|
||||
- `ORCHESTRATOR_SHARED_SECRET=storyforge-local-secret`
|
||||
- `CUTVIDEO_BASE_URL=http://<windows-lan-ip>:7860`
|
||||
- `CUTVIDEO_API_KEY=` 如果 Windows 服务启用了鉴权
|
||||
- `HUOBAO_BASE_URL=http://127.0.0.1:5678`
|
||||
- `WHISPER_BIN=` 指向你现有本地 ASR 可执行文件时填写
|
||||
|
||||
说明:
|
||||
|
||||
- 如果你单独重建 `collector`,要确保运行时仍带上 `CUTVIDEO_BASE_URL`,否则容器会退回空值
|
||||
- 当前已验证可用的 Windows `cutvideo` 地址是 `http://192.168.31.18:7860`
|
||||
|
||||
## 2. 启动基础服务
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
检查:
|
||||
|
||||
- `collector-service`:`http://127.0.0.1:8081/healthz`
|
||||
- `n8n`:`http://127.0.0.1:5670`
|
||||
- `cli-proxy-api`:`http://127.0.0.1:8317`
|
||||
- 本机 `huobao-drama`:`http://127.0.0.1:5678/health`
|
||||
|
||||
## 3. 导入 n8n workflows
|
||||
|
||||
从 `n8n/workflows/` 导入:
|
||||
|
||||
- `storyforge-analysis.json`
|
||||
- `storyforge-real-cut.json`
|
||||
- `storyforge-ai-video.json`
|
||||
|
||||
导入后:
|
||||
|
||||
- 检查每个 HTTP Request 节点的 `X-Orchestrator-Secret`
|
||||
- 如果你改了 `.env` 的 secret,这里必须同步
|
||||
|
||||
## 4. 登录与审批
|
||||
|
||||
默认超级管理员:
|
||||
|
||||
- 用户名:`kris`
|
||||
- 密码:`Asd123456.`
|
||||
|
||||
新用户注册后,需要用超级管理员审批。
|
||||
|
||||
## 5. 内容分析链路验证
|
||||
|
||||
### 文本
|
||||
|
||||
调用 `POST /v2/explore/text`
|
||||
|
||||
预期:
|
||||
|
||||
- 任务创建成功
|
||||
- `n8n` webhook 被触发
|
||||
- 任务最终进入 `completed`
|
||||
- 知识库文档里出现 transcript / style_summary / analysis / storyboards
|
||||
|
||||
已验证样例:
|
||||
|
||||
- `job_203bc8e9b20f4b1cbbc6cf7da79e46f4`
|
||||
|
||||
### 视频链接
|
||||
|
||||
调用 `POST /v2/explore/video-link`
|
||||
|
||||
前提:
|
||||
|
||||
- `yt-dlp` 可用
|
||||
- `ffmpeg` 可用
|
||||
- ASR 可调用
|
||||
|
||||
### 上传视频
|
||||
|
||||
调用 `POST /v2/explore/upload-video`
|
||||
|
||||
预期与视频链接类似,但素材来源为本地上传
|
||||
|
||||
## 6. `cutvideo` 实拍剪辑链路验证
|
||||
|
||||
调用 `POST /v2/pipelines/real-cut`
|
||||
|
||||
当前 MVP 前提:
|
||||
|
||||
- `input_dir` 必须是 Windows `cutvideo` 机器可访问的目录
|
||||
- 该目录中的素材已准备好
|
||||
|
||||
预期:
|
||||
|
||||
- 任务创建成功
|
||||
- `n8n` 调用 `collector-service` 内部 real-cut step
|
||||
- 后端记录 `provider_task_id`
|
||||
- 最终任务写回 `cutvideo_run`
|
||||
|
||||
已验证样例:
|
||||
|
||||
- `job_5ebd829c3f2144bca5c941183e75bdcd`
|
||||
- Windows 返回 `task_id=8d8f4a0cd5d9`
|
||||
- 运行目录 `20260318-093520-Windows cutvideo 联调样例`
|
||||
|
||||
## 7. `huobao-drama` AI 视频链路验证
|
||||
|
||||
调用 `POST /v2/pipelines/ai-video`
|
||||
|
||||
推荐方式:
|
||||
|
||||
- 先完成一个分析任务
|
||||
- 再把该分析任务的 `source_job_id` 传给 AI 视频任务
|
||||
|
||||
预期:
|
||||
|
||||
- 创建 drama
|
||||
- 每个分镜生成首帧、尾帧
|
||||
- 每个分镜生成视频
|
||||
- 最终 `job.result.rendered_scenes` 有完整结果
|
||||
|
||||
已验证样例:
|
||||
|
||||
- `job_01828c40377747cf914b51be360cc333`
|
||||
- `provider_task_id=10`
|
||||
- `video.task_id=qvideo-1380265978-1773799215825814468`
|
||||
- 最终视频已回写到 `job.result.rendered_scenes[0].video.video_url`
|
||||
|
||||
## 8. 当前已知卡点
|
||||
|
||||
- `cutvideo` 端到端“上传素材后自动送到 Windows 机器”还未彻底闭环
|
||||
- ASR 如果不是命令行模式,而是你现有常驻服务模式,需要再做一次入口绑定
|
||||
- `huobao-drama` 目前跑通依赖本地旧改版中的 qnaigc 兼容补丁,下一步要迁到 upstream 仓库
|
||||
41
docs/MVP_STATUS_2026-03-18.md
Normal file
41
docs/MVP_STATUS_2026-03-18.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# StoryForge MVP 状态
|
||||
|
||||
日期:2026-03-18
|
||||
|
||||
## 已跑通或已完成代码接通
|
||||
|
||||
- 多用户账号体系
|
||||
- 审批机制
|
||||
- `user -> project -> assistant / knowledge base / job / content source` 数据模型
|
||||
- 文本 / 视频链接 / 上传视频 三类分析任务创建
|
||||
- `n8n` 工作流导入、激活与触发接口
|
||||
- 本地下载器调用
|
||||
- 本地 `ffmpeg` / `whisper` 风格入口封装
|
||||
- 本地大模型内容分析、二创文案、分镜生成
|
||||
- Windows `cutvideo` API 调度与结果回写接口
|
||||
- 本机 `huobao-drama` API 调度、首尾帧生成、视频生成与结果回写接口
|
||||
- FastGPT 运行时依赖删除
|
||||
|
||||
## 已验证的真实任务
|
||||
|
||||
- 分析链路:`job_203bc8e9b20f4b1cbbc6cf7da79e46f4`
|
||||
- 实拍剪辑链路:`job_5ebd829c3f2144bca5c941183e75bdcd`
|
||||
- AI 视频链路:`job_01828c40377747cf914b51be360cc333`
|
||||
|
||||
## 已实现但仍待环境验证
|
||||
|
||||
- 现有 ASR 部署入口与 `collector-service` 的最终绑定
|
||||
|
||||
## 尚未完全跑通
|
||||
|
||||
- 用户上传实拍素材后,自动把素材转运到 Windows `cutvideo` 机器的闭环
|
||||
- 对“抖音 / bilibili / 小红书账号级内容源”的批量抓取与分析调度
|
||||
- `huobao-drama` 本地兼容补丁向 upstream 仓库的迁移、分支化和提交
|
||||
|
||||
## 下一步优先级
|
||||
|
||||
1. 把 `huobao-drama` 本地兼容补丁迁到 `/Users/kris/code/huobao-drama-upstream`
|
||||
2. 绑定你的真实 ASR 入口
|
||||
3. 决定实拍素材转运方案:共享目录优先,上传 API 作为备选
|
||||
4. 补账号级内容源抓取调度
|
||||
5. 把改动整理成提交并推送
|
||||
28
n8n/README.md
Normal file
28
n8n/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# n8n Workflows
|
||||
|
||||
本目录保存 StoryForge 的工作流导出文件,避免流程只存在于 n8n UI。
|
||||
|
||||
## 工作流
|
||||
|
||||
- `workflows/storyforge-analysis.json`:内容分析主线
|
||||
- `workflows/storyforge-real-cut.json`:Windows `cutvideo` 调度主线
|
||||
- `workflows/storyforge-ai-video.json`:`huobao-drama` AI 生成视频主线
|
||||
|
||||
## 约定
|
||||
|
||||
- 工作流内部默认通过 `http://collector:8081` 调用 `collector-service`
|
||||
- 内部调用头部使用 `X-Orchestrator-Secret: storyforge-local-secret`
|
||||
- 如果你修改了 `.env` 里的 `ORCHESTRATOR_SHARED_SECRET`,导入工作流后需要同步更新对应 HTTP Request 节点
|
||||
|
||||
## 导入
|
||||
|
||||
1. 先执行 `docker compose up -d n8n collector`
|
||||
2. 打开 `http://127.0.0.1:5670`
|
||||
3. 从 UI 导入本目录下的 3 个 JSON
|
||||
4. 激活工作流
|
||||
|
||||
## Webhook 路径
|
||||
|
||||
- `/webhook/storyforge-analysis`
|
||||
- `/webhook/storyforge-real-cut`
|
||||
- `/webhook/storyforge-ai-video`
|
||||
70
n8n/workflows/storyforge-ai-video.json
Normal file
70
n8n/workflows/storyforge-ai-video.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "StoryForge AI Video Pipeline",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "storyforge-ai-video",
|
||||
"responseMode": "onReceived",
|
||||
"options": {}
|
||||
},
|
||||
"id": "aivideo-webhook",
|
||||
"name": "AI Video Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"webhookId": "storyforge-ai-video"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{'http://collector:8081/internal/jobs/steps/ai-video/render?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-Orchestrator-Secret",
|
||||
"value": "storyforge-local-secret"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 3600000
|
||||
}
|
||||
},
|
||||
"id": "aivideo-runner",
|
||||
"name": "Run AI Video Step",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
520,
|
||||
300
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"AI Video Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Run AI Video Step",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Run AI Video Step": {
|
||||
"main": [
|
||||
[]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"pinData": {},
|
||||
"versionId": "storyforge-ai-video-v1"
|
||||
}
|
||||
70
n8n/workflows/storyforge-analysis.json
Normal file
70
n8n/workflows/storyforge-analysis.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "StoryForge Analysis Pipeline",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "storyforge-analysis",
|
||||
"responseMode": "onReceived",
|
||||
"options": {}
|
||||
},
|
||||
"id": "analysis-webhook",
|
||||
"name": "Analysis Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"webhookId": "storyforge-analysis"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{'http://collector:8081/internal/jobs/steps/analyze?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-Orchestrator-Secret",
|
||||
"value": "storyforge-local-secret"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 600000
|
||||
}
|
||||
},
|
||||
"id": "analysis-runner",
|
||||
"name": "Run Analysis Step",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
520,
|
||||
300
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Analysis Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Run Analysis Step",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Run Analysis Step": {
|
||||
"main": [
|
||||
[]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"pinData": {},
|
||||
"versionId": "storyforge-analysis-v1"
|
||||
}
|
||||
70
n8n/workflows/storyforge-real-cut.json
Normal file
70
n8n/workflows/storyforge-real-cut.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "StoryForge Real Cut Pipeline",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "storyforge-real-cut",
|
||||
"responseMode": "onReceived",
|
||||
"options": {}
|
||||
},
|
||||
"id": "realcut-webhook",
|
||||
"name": "Real Cut Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"webhookId": "storyforge-real-cut"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{'http://collector:8081/internal/jobs/steps/real-cut/run?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-Orchestrator-Secret",
|
||||
"value": "storyforge-local-secret"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 3600000
|
||||
}
|
||||
},
|
||||
"id": "realcut-runner",
|
||||
"name": "Run Real Cut Step",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
520,
|
||||
300
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Real Cut Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Run Real Cut Step",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Run Real Cut Step": {
|
||||
"main": [
|
||||
[]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"pinData": {},
|
||||
"versionId": "storyforge-real-cut-v1"
|
||||
}
|
||||
Reference in New Issue
Block a user