diff --git a/AGENTS.md b/AGENTS.md index d9227d3..ee94a9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Build a portable, versioned declarative video composition protocol and a lightwe - Protocol and domain semantics are the source of truth; UI and MCP are adapters. - No image/video generation runtime, no ComfyUI-like execution graph, no built-in media rendering, no publishing integrations. - Deployment MUST target Cloudflare Workers; do not introduce PM2/systemd/VPS runtime, fabricated hostnames, example.com domains or hardcoded ports as deployed facts. -- Production hostname MUST be a `talkincode.net` subdomain managed by Cloudflare; planned default `video-compose.talkincode.net` until actual DNS/Worker routing is verified. +- Production hostname MUST be a `talkincode.net` subdomain managed by Cloudflare; canonical target `vflow.talkincode.net`; retain `video-compose.talkincode.net` as a compatibility alias. Verify actual DNS/Worker routing before claiming it is live. - Browser UI, REST API and MCP MUST be protected with Cloudflare Access (Zero Trust SSO / service authentication as appropriate); Worker MUST still enforce per-project authorization. - GitHub-to-Workers deployment MUST be supported, preferring native Workers Builds GitHub integration; GitHub Actions + Wrangler is an alternative, never a simultaneous competing production deployer. - Distinguish design decisions from verified live infrastructure. Do not report CI, DNS, Access, R2 or D1 as configured without inspecting actual resources. diff --git a/README.md b/README.md index 5be42d8..67bb333 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ Video Compose 面向人与多个 Agent 共创视频作品。项目记录可移 - Project 下多个 Composition,共享可检索的 Asset,并采用简洁的项目级 `reader` / `writer` 读写权限隔离。 - 首页提供项目列表、搜索与新建;项目页显示编排及共享资源概览,可创建编排并选择画面规格/帧率。工作台支持返回项目、切换编排和独立直达地址。 - **角色/道具设定库**:项目级主体(Subject)有独立定义、稳定 ID、带状态(`status: candidate | approved`)的修订版本、外观/状态变体(含 `defaultVariant`)和多视角参考图集;`candidate` 状态便于修改迭代,批准为 `approved` 后冻结;分镜绑定确定版本,禁止参考设定悄然漂移。详见 [主体与参考图规范草案](docs/subjects.md)。 -- v1 JSON/YAML 声明式编排协议和可机读 Schema。 +- v1/v2 JSON/YAML 声明式编排协议和独立可机读 Schema;v2 固定参考资料来源并拒绝有损 v1 导出。 +- **参考制作工作流**:单角色面部/身体分包、道具逐格归属、独立场景与环境变体、无字序列、逐图审阅、准确的生成输入包和外部实际输入证据。MCP 提供 instructions、prompts、resources 与纯工具兜底,领域层防止旧写入口绕过。[制作规范与迁移指南](docs/reference-authoring.md)。 - 稳定 Shot ID、播放序列与分镜依赖拓扑(`dependsOn` DAG)、全局音轨(`audioTracks` 如全局 BGM/旁白)、分镜多参考图、可选参考视频、Prompt、音视频候选集(`candidates`)与选定输出(`selected`)、整数帧时长与扩展元数据。 - 不可变 R2 媒体(两阶段上传与 SHA-256 校验)与 D1 资产索引、修订历史、基于稳定 ID 的结构化 Patch、冲突检测和审阅提案(Proposal)。 -- 统一 REST/MCP 接口;React 工作台提供分镜、素材、角色与道具、协作与历史、协议标签页,保留网格、清单和依赖视图。 +- 统一 REST/MCP 接口;React 工作台提供分镜、素材、角色与道具、参考制作、协作与历史、协议标签页,保留网格、清单和依赖视图。 - 同源受权图片/视频/音频预览;按镜头保留当前页面草稿,明确保存范围与冲突处理;镜头搜索、前后跳转、排序与原子批量时长修改。 ## 技术架构 @@ -25,7 +26,7 @@ TypeScript(strict/ESM)贯穿协议、服务与 Web;npm workspaces。 - Storage: Cloudflare D1(authoritative revisions / ACL / metadata)+ R2(immutable media)。 - Web: React + Vite,预览/编辑并排,可收起导航或进入专注编辑。 - Optional: Durable Objects 用于必要时的在线协作通知。 -- **Deploy target**: Cloudflare Workers,已验证域名 `video-compose.talkincode.net`(`talkincode.net` Cloudflare 子域名),UI/API/MCP 统一受 Cloudflare Access Zero Trust SSO 保护;Agent 经受控非交互身份访问,Worker 做项目级授权。 +- **Deploy target**: Cloudflare Workers,正式入口 `vflow.talkincode.net`,旧域名 `video-compose.talkincode.net` 保留兼容(`talkincode.net` Cloudflare 子域名),UI/API/MCP 统一受 Cloudflare Access Zero Trust SSO 保护;Agent 经受控非交互身份访问,Worker 做项目级授权。 - **GitHub-to-Workers**: 首选 Cloudflare Workers Builds 与 GitHub `main` 集成部署;GitHub Actions + Wrangler 仅作为替代部署路径,两者不并行部署生产。详见 [deployment](docs/deployment.md)。 详见 [architecture](docs/architecture.md)。 @@ -52,6 +53,10 @@ npm run test:browser 通过目标提交的 CI 后,已登录正确 Cloudflare 账户的维护者运行 `npm run deploy`。部署前后核对 Worker 版本、D1/R2 绑定、Access 保护和登录后的真实界面,不能把本地构建成功当成线上发布成功。 +## MCP 接入 + +正式网站:`https://vflow.talkincode.net`;Remote MCP:`https://vflow.talkincode.net/mcp`。OAuth discovery 根据请求域名生成 issuer 和端点;UI/API 使用 Cloudflare Access,MCP 使用 OAuth Bearer 与项目 ACL。已有旧域名连接保持兼容,新配置使用 vflow。 + ## Quality & contributions 请先阅读 [AGENTS.md](AGENTS.md) 及 [roadmap acceptance matrix](docs/roadmap.md#验收矩阵业务能力覆盖矩阵)。新增一级能力必须补充 Happy Path E2E;高风险、权限及状态修改必须验证失败和恢复路径。禁止为功能开发而扩大非目标范围。 diff --git a/apps/api/migrations/0002_mcp_oauth.sql b/apps/api/migrations/0002_mcp_oauth.sql new file mode 100644 index 0000000..1133a98 --- /dev/null +++ b/apps/api/migrations/0002_mcp_oauth.sql @@ -0,0 +1,52 @@ +-- Cloudflare D1 schema for MCP OAuth 2.1 in Video Compose +CREATE TABLE IF NOT EXISTS mcp_clients ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + redirect_uris_json TEXT NOT NULL, + grant_types_json TEXT NOT NULL, + response_types_json TEXT NOT NULL, + scope TEXT NOT NULL, + auth_method TEXT NOT NULL, + secret_hash TEXT, + owner TEXT, + disabled INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_used_at INTEGER +); + +CREATE TABLE IF NOT EXISTS mcp_grants ( + id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + owner TEXT NOT NULL, + scope TEXT NOT NULL, + state TEXT, + resource TEXT, + code_challenge TEXT NOT NULL, + code_challenge_method TEXT NOT NULL, + code_hash TEXT, + csrf TEXT NOT NULL, + approved INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + used_at INTEGER, + FOREIGN KEY (client_id) REFERENCES mcp_clients(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS mcp_tokens ( + id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('access', 'refresh')), + token_hash TEXT NOT NULL UNIQUE, + scopes TEXT NOT NULL, + resource TEXT NOT NULL, + owner TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + FOREIGN KEY (client_id) REFERENCES mcp_clients(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_mcp_grants_code_hash ON mcp_grants(code_hash); +CREATE INDEX IF NOT EXISTS idx_mcp_tokens_token_hash ON mcp_tokens(token_hash); diff --git a/apps/api/migrations/0003_reference_authoring.sql b/apps/api/migrations/0003_reference_authoring.sql new file mode 100644 index 0000000..e48e105 --- /dev/null +++ b/apps/api/migrations/0003_reference_authoring.sql @@ -0,0 +1,14 @@ +-- Immutable reference-workflow revisions and external execution evidence. +-- Existing projects without a policy remain legacy; no old composition is rewritten. +CREATE TABLE IF NOT EXISTS reference_documents ( + project_id TEXT NOT NULL, + id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (project_id, id) +); +CREATE TABLE IF NOT EXISTS reference_evidence ( + project_id TEXT NOT NULL, + id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (project_id, id) +); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f73c8a7..aca4430 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,10 +3,22 @@ import { type DomainErrorCode, VideoComposeDomainService, } from "@video-compose/domain"; +import type { ActorIdentity } from "@video-compose/protocol"; import { Hono } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import { type AccessAuthConfig, extractActorFromHeaders } from "./auth.ts"; import { handleMcpJsonRpc, type JsonRpcRequest } from "./mcp.ts"; +import { + hashToken, + InMemoryOAuthStore, + type McpClient, + type McpGrant, + type OAuthStore, + randomToken, + renderConsentPage, + renderSignInPage, + verifyPkce, +} from "./oauth.ts"; function statusForDomainError(code: DomainErrorCode): ContentfulStatusCode { switch (code) { @@ -28,7 +40,7 @@ function statusForDomainError(code: DomainErrorCode): ContentfulStatusCode { return 422; case "INTERNAL_ERROR": default: - return 500; + return code === "INTERNAL_ERROR" ? 500 : 422; } } @@ -44,9 +56,39 @@ function decodeBase64Bytes(base64: string): Uint8Array { export function createApiApp( domain: VideoComposeDomainService = new VideoComposeDomainService(), authConfig?: AccessAuthConfig, -): { app: Hono; domain: VideoComposeDomainService } { + oauthStore: OAuthStore = new InMemoryOAuthStore(), +): { app: Hono; domain: VideoComposeDomainService; oauthStore: OAuthStore } { const app = new Hono(); + const corsHeaders: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": + "Authorization, Content-Type, CF-Access-Client-Id, CF-Access-Client-Secret, x-agent-id", + }; + + async function resolveActor(headers: Headers): Promise { + const authHeader = headers.get("authorization")?.trim(); + if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) { + const rawToken = authHeader.substring(7).trim(); + if (rawToken) { + const hashed = await hashToken(rawToken); + const token = await oauthStore.getTokenByHash(hashed); + if (token && !token.revokedAt && token.expiresAt > Date.now()) { + const explicitAgentId = headers.get("x-agent-id")?.trim(); + oauthStore + .updateToken({ ...token, lastUsedAt: Date.now() }) + .catch(() => {}); + return { + actorId: token.owner, + actorKind: explicitAgentId ? "agent" : "human", + }; + } + } + } + return extractActorFromHeaders(headers, authConfig); + } + app.onError((err, c) => { if (err instanceof DomainError) { return c.json( @@ -71,49 +113,567 @@ export function createApiApp( ); }); + // CORS preflight for machine & OAuth endpoints + app.options("/mcp", () => new Response(null, { status: 204, headers: corsHeaders })); + app.options("/oauth/*", () => new Response(null, { status: 204, headers: corsHeaders })); + app.options("/.well-known/*", () => new Response(null, { status: 204, headers: corsHeaders })); + + // RFC 9728 Protected Resource Metadata + const handleProtectedResource = (c: { req: { url: string }; json: (data: unknown, status: ContentfulStatusCode, headers: Record) => Response }) => { + const origin = new URL(c.req.url).origin; + return c.json( + { + resource: `${origin}/mcp`, + authorization_servers: [origin], + scopes_supported: ["video-compose.read", "video-compose.write", "video-compose.all"], + bearer_methods_supported: ["header"], + }, + 200, + corsHeaders, + ); + }; + app.get("/.well-known/oauth-protected-resource", (c) => handleProtectedResource(c)); + app.get("/.well-known/oauth-protected-resource/mcp", (c) => handleProtectedResource(c)); + + // RFC 8414 Authorization Server Metadata + app.get("/.well-known/oauth-authorization-server", (c) => { + const origin = new URL(c.req.url).origin; + return c.json( + { + issuer: origin, + authorization_endpoint: `${origin}/oauth/authorize`, + token_endpoint: `${origin}/oauth/token`, + registration_endpoint: `${origin}/oauth/register`, + revocation_endpoint: `${origin}/oauth/revoke`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: [ + "none", + "client_secret_post", + "client_secret_basic", + ], + code_challenge_methods_supported: ["S256"], + scopes_supported: ["video-compose.read", "video-compose.write", "video-compose.all"], + }, + 200, + corsHeaders, + ); + }); + + // RFC 7591 Dynamic Client Registration + app.post("/oauth/register", async (c) => { + let body: Record = {}; + try { + body = (await c.req.json()) as Record; + } catch { + return c.json( + { + error: "invalid_client_metadata", + error_description: "Invalid JSON body", + }, + 400, + corsHeaders, + ); + } + const redirectUris = Array.isArray(body.redirect_uris) + ? (body.redirect_uris as string[]) + : []; + if (redirectUris.length === 0) { + return c.json( + { + error: "invalid_client_metadata", + error_description: "redirect_uris is required", + }, + 400, + corsHeaders, + ); + } + const clientId = `mcpc_${randomToken(16)}`; + const clientName = String(body.client_name || "MCP Client").slice(0, 60); + const authMethod = String(body.token_endpoint_auth_method || "none"); + let secret: string | undefined; + let secretHash: string | undefined; + if (authMethod !== "none") { + secret = randomToken(32); + secretHash = await hashToken(secret); + } + const client: McpClient = { + id: clientId, + name: clientName, + redirectUris, + grantTypes: + Array.isArray(body.grant_types) && body.grant_types.length > 0 + ? (body.grant_types as string[]) + : ["authorization_code", "refresh_token"], + responseTypes: ["code"], + scope: String(body.scope || "video-compose.all"), + authMethod, + secretHash, + disabled: false, + createdAt: Date.now(), + }; + await oauthStore.createClient(client); + return c.json( + { + client_id: client.id, + client_name: client.name, + redirect_uris: client.redirectUris, + grant_types: client.grantTypes, + response_types: client.responseTypes, + token_endpoint_auth_method: client.authMethod, + scope: client.scope, + client_id_issued_at: Math.floor(client.createdAt / 1000), + ...(secret + ? { client_secret: secret, client_secret_expires_at: 0 } + : {}), + }, + 201, + corsHeaders, + ); + }); + + // RFC 8414 / OAuth 2.1 Authorize consent screen + app.get("/oauth/authorize", async (c) => { + const clientId = c.req.query("client_id")?.trim(); + const redirectUri = c.req.query("redirect_uri")?.trim(); + const responseType = c.req.query("response_type")?.trim(); + const codeChallenge = c.req.query("code_challenge")?.trim(); + const codeChallengeMethod = c.req.query("code_challenge_method")?.trim(); + const state = c.req.query("state")?.trim(); + const scope = c.req.query("scope")?.trim(); + const resource = c.req.query("resource")?.trim(); + + if (!clientId) { + return c.text("Missing client_id parameter", 400); + } + const client = await oauthStore.getClient(clientId); + if (!client || client.disabled) { + return c.text("Unknown or disabled client_id", 400); + } + const finalRedirectUri = + redirectUri || (client.redirectUris.length === 1 ? client.redirectUris[0] : ""); + if (!finalRedirectUri || !client.redirectUris.includes(finalRedirectUri)) { + return c.text("Invalid or unregistered redirect_uri", 400); + } + if (responseType !== "code") { + const u = new URL(finalRedirectUri); + u.searchParams.set("error", "unsupported_response_type"); + if (state) u.searchParams.set("state", state); + return c.redirect(u.toString(), 302); + } + if (!codeChallenge || codeChallengeMethod !== "S256") { + const u = new URL(finalRedirectUri); + u.searchParams.set("error", "invalid_request"); + u.searchParams.set("error_description", "PKCE with code_challenge_method=S256 is required"); + if (state) u.searchParams.set("state", state); + return c.redirect(u.toString(), 302); + } + + const actor = await resolveActor(c.req.raw.headers); + if (!actor) { + return c.html(renderSignInPage(c.req.url), 401); + } + + const grant: McpGrant = { + id: `mcpgr_${randomToken(16)}`, + clientId: client.id, + redirectUri: finalRedirectUri, + owner: actor.actorId, + scope: scope || client.scope || "video-compose.all", + state, + resource, + codeChallenge, + codeChallengeMethod: "S256", + csrf: randomToken(16), + approved: false, + expiresAt: Date.now() + 10 * 60 * 1000, + createdAt: Date.now(), + }; + await oauthStore.createGrant(grant); + + return c.html( + renderConsentPage({ + grantId: grant.id, + clientName: client.name, + scope: grant.scope, + ownerEmail: actor.actorId, + csrf: grant.csrf, + }), + ); + }); + + // User consent decision endpoint + app.post("/oauth/authorize/decide", async (c) => { + const body = await c.req.parseBody(); + const grantId = String(body.grant || "").trim(); + const csrf = String(body.csrf || "").trim(); + const decision = String(body.decision || "").trim(); + + const grant = await oauthStore.getGrant(grantId); + if (!grant || grant.expiresAt < Date.now() || grant.approved || grant.usedAt) { + return c.text("Authorization request expired or already processed", 400); + } + if (!csrf || csrf !== grant.csrf) { + return c.text("Invalid CSRF verification token", 403); + } + const actor = await resolveActor(c.req.raw.headers); + if (!actor || actor.actorId !== grant.owner) { + return c.text("Please sign in as the owner who initiated the authorization", 403); + } + + const redirectUrl = new URL(grant.redirectUri); + if (decision !== "allow") { + redirectUrl.searchParams.set("error", "access_denied"); + redirectUrl.searchParams.set("error_description", "User denied authorization"); + if (grant.state) redirectUrl.searchParams.set("state", grant.state); + return c.redirect(redirectUrl.toString(), 302); + } + + const code = randomToken(32); + grant.codeHash = await hashToken(code); + grant.approved = true; + grant.expiresAt = Date.now() + 5 * 60 * 1000; + await oauthStore.updateGrant(grant); + + redirectUrl.searchParams.set("code", code); + if (grant.state) redirectUrl.searchParams.set("state", grant.state); + redirectUrl.searchParams.set("iss", new URL(c.req.url).origin); + return c.redirect(redirectUrl.toString(), 302); + }); + + // RFC 6749 Token Exchange + app.post("/oauth/token", async (c) => { + let body: Record = {}; + const contentType = c.req.header("content-type") || ""; + if (contentType.includes("application/json")) { + body = ((await c.req.json().catch(() => ({}))) as Record) || {}; + } else { + const parsed = await c.req.parseBody().catch(() => ({})); + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === "string") body[k] = v; + } + } + + const grantType = body.grant_type; + let clientId = body.client_id; + let clientSecret = body.client_secret; + + const authHeader = c.req.header("authorization") || ""; + if (authHeader.toLowerCase().startsWith("basic ")) { + try { + const credentials = atob(authHeader.substring(6)).split(":"); + clientId = decodeURIComponent(credentials[0] || ""); + clientSecret = decodeURIComponent(credentials[1] || ""); + } catch { + // ignore malformed basic header + } + } + + if (!clientId) { + return c.json( + { error: "invalid_client", error_description: "Missing client_id" }, + 400, + corsHeaders, + ); + } + + const client = await oauthStore.getClient(clientId); + if (!client || client.disabled) { + return c.json( + { error: "invalid_client", error_description: "Unknown or disabled client" }, + 401, + corsHeaders, + ); + } + + if (client.authMethod !== "none") { + if (!clientSecret) { + return c.json( + { error: "invalid_client", error_description: "Missing client_secret" }, + 401, + corsHeaders, + ); + } + const hashedSecret = await hashToken(clientSecret); + if (hashedSecret !== client.secretHash) { + return c.json( + { error: "invalid_client", error_description: "Invalid client credentials" }, + 401, + corsHeaders, + ); + } + } + + if (grantType === "authorization_code") { + const code = body.code?.trim(); + const codeVerifier = body.code_verifier?.trim(); + const redirectUri = body.redirect_uri?.trim(); + + if (!code || !codeVerifier) { + return c.json( + { + error: "invalid_request", + error_description: "Missing code or code_verifier", + }, + 400, + corsHeaders, + ); + } + + const codeHash = await hashToken(code); + const grant = await oauthStore.getGrantByCodeHash(codeHash); + if (!grant || !grant.approved || grant.usedAt || grant.expiresAt < Date.now()) { + return c.json( + { + error: "invalid_grant", + error_description: "Authorization code invalid or expired", + }, + 400, + corsHeaders, + ); + } + + if (redirectUri && grant.redirectUri !== redirectUri) { + return c.json( + { + error: "invalid_grant", + error_description: "redirect_uri mismatch", + }, + 400, + corsHeaders, + ); + } + + const validPkce = await verifyPkce( + codeVerifier, + grant.codeChallenge, + grant.codeChallengeMethod, + ); + if (!validPkce) { + return c.json( + { + error: "invalid_grant", + error_description: "PKCE verification failed", + }, + 400, + corsHeaders, + ); + } + + grant.usedAt = Date.now(); + await oauthStore.updateGrant(grant); + + const rawAccessToken = `vctk_${randomToken(32)}`; + const rawRefreshToken = `vcrf_${randomToken(32)}`; + const tokenHash = await hashToken(rawAccessToken); + const refreshTokenHash = await hashToken(rawRefreshToken); + const now = Date.now(); + + await oauthStore.createToken({ + id: `mcptk_${randomToken(16)}`, + clientId: grant.clientId, + kind: "access", + tokenHash, + scopes: grant.scope, + resource: grant.resource || "", + owner: grant.owner, + expiresAt: now + 3600 * 1000, + createdAt: now, + }); + + await oauthStore.createToken({ + id: `mcptk_${randomToken(16)}`, + clientId: grant.clientId, + kind: "refresh", + tokenHash: refreshTokenHash, + scopes: grant.scope, + resource: grant.resource || "", + owner: grant.owner, + expiresAt: now + 30 * 86400 * 1000, + createdAt: now, + }); + + return c.json( + { + access_token: rawAccessToken, + token_type: "Bearer", + expires_in: 3600, + refresh_token: rawRefreshToken, + scope: grant.scope, + }, + 200, + corsHeaders, + ); + } + + if (grantType === "refresh_token") { + const refreshToken = body.refresh_token?.trim(); + if (!refreshToken) { + return c.json( + { + error: "invalid_request", + error_description: "Missing refresh_token", + }, + 400, + corsHeaders, + ); + } + + const hashed = await hashToken(refreshToken); + const oldToken = await oauthStore.getTokenByHash(hashed); + if ( + !oldToken || + oldToken.kind !== "refresh" || + oldToken.revokedAt || + oldToken.expiresAt < Date.now() || + oldToken.clientId !== client.id + ) { + return c.json( + { + error: "invalid_grant", + error_description: "Refresh token invalid, revoked, or expired", + }, + 400, + corsHeaders, + ); + } + + await oauthStore.revokeToken(hashed); + + const rawAccessToken = `vctk_${randomToken(32)}`; + const rawRefreshToken = `vcrf_${randomToken(32)}`; + const tokenHash = await hashToken(rawAccessToken); + const refreshTokenHash = await hashToken(rawRefreshToken); + const now = Date.now(); + + await oauthStore.createToken({ + id: `mcptk_${randomToken(16)}`, + clientId: oldToken.clientId, + kind: "access", + tokenHash, + scopes: oldToken.scopes, + resource: oldToken.resource, + owner: oldToken.owner, + expiresAt: now + 3600 * 1000, + createdAt: now, + }); + + await oauthStore.createToken({ + id: `mcptk_${randomToken(16)}`, + clientId: oldToken.clientId, + kind: "refresh", + tokenHash: refreshTokenHash, + scopes: oldToken.scopes, + resource: oldToken.resource, + owner: oldToken.owner, + expiresAt: now + 30 * 86400 * 1000, + createdAt: now, + }); + + return c.json( + { + access_token: rawAccessToken, + token_type: "Bearer", + expires_in: 3600, + refresh_token: rawRefreshToken, + scope: oldToken.scopes, + }, + 200, + corsHeaders, + ); + } + + return c.json( + { + error: "unsupported_grant_type", + error_description: "Only authorization_code and refresh_token are supported", + }, + 400, + corsHeaders, + ); + }); + + // RFC 7009 Token Revocation + app.post("/oauth/revoke", async (c) => { + let body: Record = {}; + const contentType = c.req.header("content-type") || ""; + if (contentType.includes("application/json")) { + body = ((await c.req.json().catch(() => ({}))) as Record) || {}; + } else { + const parsed = await c.req.parseBody().catch(() => ({})); + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === "string") body[k] = v; + } + } + const token = body.token?.trim(); + if (token) { + const hashed = await hashToken(token); + await oauthStore.revokeToken(hashed); + } + return c.json({ revoked: true }, 200, corsHeaders); + }); + // Remote MCP endpoint over authenticated Streamable HTTP app.post("/mcp", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const rpc = (await c.req.json()) as JsonRpcRequest; const response = await handleMcpJsonRpc(domain, actor, rpc); - const status = response.error?.code === -32001 ? 401 : 200; - return c.json(response, status); + const origin = new URL(c.req.url).origin; + if (response.error?.code === -32001) { + return c.json(response, 401, { + ...corsHeaders, + "WWW-Authenticate": `Bearer realm="video-compose", resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`, + }); + } + return c.json(response, 200, corsHeaders); }); + app.get("/v1/projects/:projectId/authoring/workflow", async c => c.json(await domain.authoring.workflow(await resolveActor(c.req.raw.headers), c.req.param("projectId"), c.req.query("language") === "en" ? "en" : "zh"))); + app.get("/v1/projects/:projectId/authoring/impact/:id/:revision", async c => c.json(await domain.authoring.impact(await resolveActor(c.req.raw.headers), c.req.param("projectId"), { id: c.req.param("id"), revision: Number(c.req.param("revision")) }))); + app.get("/v1/projects/:projectId/authoring/documents", async c => c.json(await domain.authoring.list(await resolveActor(c.req.raw.headers), c.req.param("projectId")))); + app.get("/v1/projects/:projectId/authoring/documents/:id", async c => c.json(await domain.authoring.get(await resolveActor(c.req.raw.headers), c.req.param("projectId"), c.req.param("id"), c.req.query("revision") === undefined ? undefined : Number(c.req.query("revision"))))); + app.post("/v1/projects/:projectId/authoring/documents", async c => c.json(await domain.authoring.put(await resolveActor(c.req.raw.headers), { ...await c.req.json(), projectId: c.req.param("projectId") }), 201)); + app.post("/v1/projects/:projectId/authoring/documents/:id/review", async c => c.json(await domain.authoring.review(await resolveActor(c.req.raw.headers), { ...await c.req.json(), projectId: c.req.param("projectId"), id: c.req.param("id") }))); + app.post("/v1/projects/:projectId/authoring/documents/:id/approve", async c => c.json(await domain.authoring.approve(await resolveActor(c.req.raw.headers), { ...await c.req.json(), projectId: c.req.param("projectId"), id: c.req.param("id") }))); + app.post("/v1/projects/:projectId/authoring/validate", async c => c.json(await domain.authoring.validate(await resolveActor(c.req.raw.headers), c.req.param("projectId"), (await c.req.json()).document))); + app.post("/v1/projects/:projectId/authoring/prepare", async c => { const body = await c.req.json(); return c.json(await domain.authoring.prepare(await resolveActor(c.req.raw.headers), c.req.param("projectId"), body.request, body.workflowContext)); }); + app.get("/v1/projects/:projectId/authoring/evidence", async c => c.json(await domain.authoring.evidence(await resolveActor(c.req.raw.headers), c.req.param("projectId")))); + app.post("/v1/projects/:projectId/authoring/evidence", async c => c.json(await domain.authoring.recordEvidence(await resolveActor(c.req.raw.headers), { ...await c.req.json(), projectId: c.req.param("projectId") }), 201)); + // REST /v1/projects app.get("/v1/projects", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const projects = await domain.listProjects(actor); return c.json({ projects }); }); app.post("/v1/projects", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const project = await domain.createProject(actor, body); return c.json({ project }, 201); }); app.get("/v1/projects/:projectId", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const project = await domain.getProject(actor, c.req.param("projectId")); return c.json({ project }); }); app.get("/v1/projects/:projectId/access", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); return c.json( await domain.getProjectAccess(actor, c.req.param("projectId")), ); }); app.get("/v1/projects/:projectId/overview", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); return c.json(await domain.getProjectOverview(actor, c.req.param("projectId"))); }); app.put("/v1/projects/:projectId/members/:targetActorId", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const project = await domain.setProjectMemberRole(actor, { projectId: c.req.param("projectId"), @@ -125,7 +685,7 @@ export function createApiApp( // REST /v1/projects/:projectId/assets app.get("/v1/projects/:projectId/assets", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const mimePrefix = c.req.query("mimePrefix"); const assets = await domain.listAssets( actor, @@ -139,7 +699,7 @@ export function createApiApp( ["GET", "HEAD"], "/v1/projects/:projectId/assets/:assetId/content", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const projectId = c.req.param("projectId"); const assetId = c.req.param("assetId"); const rawRange = c.req.header("Range"); @@ -213,7 +773,7 @@ export function createApiApp( ); app.post("/v1/projects/:projectId/assets/prepare", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const session = await domain.prepareAssetUpload(actor, { ...body, @@ -223,7 +783,7 @@ export function createApiApp( }); app.put("/v1/projects/:projectId/assets/upload/:sessionId", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const contentType = c.req.header("content-type") ?? ""; let bytes: Uint8Array; if (contentType.includes("application/json")) { @@ -242,7 +802,7 @@ export function createApiApp( }); app.post("/v1/projects/:projectId/assets/finalize", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const asset = await domain.finalizeAssetUpload(actor, { projectId: c.req.param("projectId"), @@ -252,7 +812,7 @@ export function createApiApp( }); app.delete("/v1/projects/:projectId/assets/:assetId", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const result = await domain.deleteAsset( actor, c.req.param("projectId"), @@ -263,7 +823,7 @@ export function createApiApp( // REST /v1/projects/:projectId/subjects app.get("/v1/projects/:projectId/subjects", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const kind = c.req.query("kind") as "character" | "prop" | undefined; const subjects = await domain.listSubjects( actor, @@ -274,7 +834,7 @@ export function createApiApp( }); app.post("/v1/projects/:projectId/subjects", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const subject = await domain.createSubject(actor, { ...body, @@ -284,7 +844,7 @@ export function createApiApp( }); app.get("/v1/projects/:projectId/subjects/:subjectId", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const revParam = c.req.query("revision"); const result = await domain.getSubject( actor, @@ -296,9 +856,10 @@ export function createApiApp( }); app.patch("/v1/projects/:projectId/subjects/:subjectId", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const snapshot = await domain.patchSubject(actor, { + workflowContext: body.workflowContext, projectId: c.req.param("projectId"), subjectId: c.req.param("subjectId"), baseRevision: Number(body.baseRevision), @@ -309,9 +870,10 @@ export function createApiApp( }); app.post("/v1/projects/:projectId/subjects/:subjectId/references", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const snapshot = await domain.attachSubjectReference(actor, { + workflowContext: body.workflowContext, projectId: c.req.param("projectId"), subjectId: c.req.param("subjectId"), baseRevision: Number(body.baseRevision), @@ -323,9 +885,10 @@ export function createApiApp( }); app.post("/v1/projects/:projectId/subjects/:subjectId/approve", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const snapshot = await domain.approveSubjectRevision(actor, { + workflowContext: body.workflowContext, projectId: c.req.param("projectId"), subjectId: c.req.param("subjectId"), revision: Number(body.revision), @@ -335,7 +898,7 @@ export function createApiApp( }); app.get("/v1/projects/:projectId/subjects/:subjectId/usage", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const usage = await domain.getSubjectUsage( actor, c.req.param("projectId"), @@ -346,7 +909,7 @@ export function createApiApp( // REST /v1/projects/:projectId/compositions app.get("/v1/projects/:projectId/compositions", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const compositions = await domain.listCompositions( actor, c.req.param("projectId"), @@ -355,9 +918,10 @@ export function createApiApp( }); app.post("/v1/projects/:projectId/compositions", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const revisionRecord = await domain.createComposition(actor, { + workflowContext: body.workflowContext, projectId: c.req.param("projectId"), document: body.document, operationId: body.operationId, @@ -366,9 +930,11 @@ export function createApiApp( }); app.post("/v1/projects/:projectId/compositions/import", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const revisionRecord = await domain.importCompositionBundle(actor, { + baseRevision: body.baseRevision, + workflowContext: body.workflowContext, projectId: c.req.param("projectId"), payload: String(body.payload ?? ""), format: body.format === "yaml" ? "yaml" : "json", @@ -379,7 +945,7 @@ export function createApiApp( }); app.get("/v1/projects/:projectId/compositions/:compositionId", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const revParam = c.req.query("revision"); const result = await domain.getComposition( actor, @@ -391,9 +957,10 @@ export function createApiApp( }); app.patch("/v1/projects/:projectId/compositions/:compositionId", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const result = await domain.patchComposition(actor, { + workflowContext: body.workflowContext, projectId: c.req.param("projectId"), compositionId: c.req.param("compositionId"), baseRevision: Number(body.baseRevision), @@ -407,7 +974,7 @@ export function createApiApp( app.get( "/v1/projects/:projectId/compositions/:compositionId/history", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const history = await domain.listCompositionHistory( actor, c.req.param("projectId"), @@ -420,9 +987,10 @@ export function createApiApp( app.post( "/v1/projects/:projectId/compositions/:compositionId/restore", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const revisionRecord = await domain.restoreCompositionRevision(actor, { + workflowContext: body.workflowContext, projectId: c.req.param("projectId"), compositionId: c.req.param("compositionId"), targetRevision: Number(body.targetRevision), @@ -435,7 +1003,7 @@ export function createApiApp( app.get( "/v1/projects/:projectId/compositions/:compositionId/export", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const format = c.req.query("format") === "yaml" ? "yaml" : "json"; const revParam = c.req.query("revision"); const exported = await domain.exportCompositionBundle(actor, { @@ -443,6 +1011,7 @@ export function createApiApp( compositionId: c.req.param("compositionId"), revision: revParam ? Number(revParam) : undefined, format, + targetVersion: c.req.query("targetVersion") === "video-compose/v1" ? "video-compose/v1" : undefined, }); return c.json(exported); }, @@ -450,7 +1019,7 @@ export function createApiApp( // REST /v1/projects/:projectId/proposals app.get("/v1/projects/:projectId/proposals", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const compositionId = c.req.query("compositionId"); const proposals = await domain.listProposals( actor, @@ -461,7 +1030,7 @@ export function createApiApp( }); app.post("/v1/projects/:projectId/proposals", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const proposal = await domain.submitProposal(actor, { projectId: c.req.param("projectId"), @@ -475,9 +1044,10 @@ export function createApiApp( }); app.post("/v1/projects/:projectId/proposals/:proposalId/approve", async (c) => { - const actor = extractActorFromHeaders(c.req.raw.headers, authConfig); + const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const result = await domain.approveProposal(actor, { + workflowContext: body.workflowContext, projectId: c.req.param("projectId"), proposalId: c.req.param("proposalId"), operationId: String(body.operationId ?? ""), @@ -485,5 +1055,5 @@ export function createApiApp( return c.json(result); }); - return { app, domain }; + return { app, domain, oauthStore }; } diff --git a/apps/api/src/authoring-mcp.ts b/apps/api/src/authoring-mcp.ts new file mode 100644 index 0000000..794073b --- /dev/null +++ b/apps/api/src/authoring-mcp.ts @@ -0,0 +1,309 @@ +import type { VideoComposeDomainService } from "@video-compose/domain"; +import { + AUTHORING_POLICY_VERSION, + AUTHORING_PROMPTS, + AUTHORING_RULES, + AUTHORING_SERVER_INSTRUCTIONS, + MATERIAL_DOCUMENT_JSON_SCHEMA, + GENERATION_INPUT_JSON_SCHEMA, + renderAuthoringPrompt, + type ActorIdentity, +} from "@video-compose/protocol"; +import type { JsonRpcRequest, McpToolDefinition } from "./mcp.ts"; + +const project = { type: "string", description: "Authorized project ID" }; +const workflowContext = { + type: "object", + description: + "Use workflowContext returned by authoring_workflow_get; rules must be read before authoring.", + properties: { + projectId: project, + policyVersion: { const: AUTHORING_POLICY_VERSION }, + policyRevision: { type: "integer", minimum: 1 }, + }, + required: ["projectId", "policyVersion", "policyRevision"], +}; +const mutations = { + projectId: project, + id: { type: "string" }, + baseRevision: { type: "integer", minimum: 0 }, + operationId: { type: "string" }, + workflowContext, +}; +export const AUTHORING_TOOLS: McpToolDefinition[] = [ + { + name: "authoring_dependencies_get", + description: + "Read downstream materials and composition revisions affected by a pinned source, without rewriting history.", + inputSchema: { + type: "object", + properties: { + projectId: project, + id: { type: "string" }, + revision: { type: "integer", minimum: 1 }, + }, + required: ["projectId", "id", "revision"], + }, + }, + { + name: "authoring_workflow_get", + description: + "READ FIRST: reference rules, templates and workflowContext before composition/reference authoring. Mixed boards are archive-only; use isolated face/body/prop/scene views and text-free storyboard tiles.", + inputSchema: { + type: "object", + properties: { projectId: project, language: { enum: ["zh", "en"] } }, + required: ["projectId"], + }, + }, + { + name: "authoring_document_list", + description: + "List versioned scene, reference pack, storyboard and policy manifests.", + inputSchema: { + type: "object", + properties: { projectId: project }, + required: ["projectId"], + }, + }, + { + name: "authoring_document_get", + description: + "Read one exact material revision; never implicitly replace a pinned reference with latest.", + inputSchema: { + type: "object", + properties: { + projectId: project, + id: { type: "string" }, + revision: { type: "integer" }, + }, + required: ["projectId", "id"], + }, + }, + { + name: "authoring_document_put", + description: + "Create a new immutable candidate revision after reading authoring_workflow_get. Full typed manifest; reviews supplied through CRUD are ignored. Images/crops must already be uploaded.", + inputSchema: { + type: "object", + properties: { ...mutations, document: MATERIAL_DOCUMENT_JSON_SCHEMA }, + required: [ + "projectId", + "document", + "baseRevision", + "operationId", + "workflowContext", + ], + }, + }, + { + name: "authoring_document_review", + description: + "Explicit per-view/panel visual review, with evidence and pass/fail/unknown checks. Decoding success is not approval. Agents must use method external.", + inputSchema: { + type: "object", + properties: { + ...mutations, + targets: { type: "array", items: { type: "string" } }, + review: { + type: "object", + properties: { + decision: { enum: ["accepted", "rejected", "unknown"] }, + method: { enum: ["human", "external"] }, + evidence: { type: "string" }, + reviewer: { type: "string" }, + checks: { + type: "object", + additionalProperties: { enum: ["pass", "fail", "unknown"] }, + }, + notes: { type: "string" }, + }, + }, + }, + required: [ + "projectId", + "id", + "baseRevision", + "operationId", + "targets", + "review", + "workflowContext", + ], + }, + }, + { + name: "authoring_document_approve", + description: + "Approve only separately reviewed valid reference material. Produces a new immutable approved revision; cannot approve mixed moodboards or unknown checks.", + inputSchema: { + type: "object", + properties: mutations, + required: [ + "projectId", + "id", + "baseRevision", + "operationId", + "workflowContext", + ], + }, + }, + { + name: "reference_plan_validate", + description: + "Read-only validation of typed reference plans, ownership/crops, text-free storyboard reviews and dependencies; returns diagnostics and repair actions.", + inputSchema: { + type: "object", + properties: { + projectId: project, + document: MATERIAL_DOCUMENT_JSON_SCHEMA, + }, + required: ["projectId", "document"], + }, + }, + { + name: "generation_inputs_prepare", + description: + "Prepare exact approved input tiles and hashes for a pinned shot. Verify target capabilities, roles and count; never use whole grids as literal first frames. Does not generate or spend credits.", + inputSchema: { + type: "object", + properties: { + projectId: project, + workflowContext, + request: GENERATION_INPUT_JSON_SCHEMA, + }, + required: ["projectId", "workflowContext", "request"], + }, + }, + { + name: "generation_evidence_record", + description: + "Record external execution inputs/output assets against a verified packet. Reports differences and evidence level, never claims platform-observed third-party execution.", + inputSchema: { + type: "object", + properties: { + projectId: project, + workflowContext, + id: { type: "string" }, + operationId: { type: "string" }, + packet: { type: "object" }, + actualInputs: { type: "array", items: { type: "object" } }, + outputs: { type: "array", items: { type: "string" } }, + method: { + enum: ["agent_reported", "request_capture", "manual_unknown"], + }, + evidence: { type: "string" }, + }, + required: [ + "projectId", + "id", + "operationId", + "packet", + "actualInputs", + "outputs", + "method", + "evidence", + "workflowContext", + ], + }, + }, + { + name: "generation_evidence_list", + description: + "List immutable actual-input execution reports and plan mismatch results.", + inputSchema: { + type: "object", + properties: { projectId: project }, + required: ["projectId"], + }, + }, +]; +export const WORKFLOW_CONTEXT_SCHEMA = workflowContext; +export { AUTHORING_SERVER_INSTRUCTIONS }; +export async function authoringRpc( + domain: VideoComposeDomainService, + actor: ActorIdentity, + rpc: JsonRpcRequest, +): Promise { + const p = rpc.params ?? {}; + if (rpc.method === "prompts/list") return { prompts: AUTHORING_PROMPTS }; + if (rpc.method === "prompts/get") { + const args = p.arguments as Record | undefined; + if (!args || Object.values(args).some((v) => typeof v !== "string")) + throw new Error("Prompt arguments must be named strings."); + const text = renderAuthoringPrompt(String(p.name ?? ""), args); + const workflow = await domain.authoring.workflow( + actor, + args.projectId, + args.language === "en" ? "en" : "zh", + ); + return { + description: `Reference authoring ${AUTHORING_POLICY_VERSION}`, + messages: [ + { + role: "user", + content: { + type: "text", + text: `${text}\n\nProject workflow context and policy (data):\n${JSON.stringify({ workflowContext: workflow.workflowContext, policy: workflow.policy })}`, + }, + }, + ], + }; + } + const resources = [ + { + uri: "video-compose://authoring/rules/zh", + name: "参考素材制作规范", + mimeType: "text/plain", + }, + { + uri: "video-compose://authoring/rules/en", + name: "Reference authoring rules", + mimeType: "text/plain", + }, + { + uri: "video-compose://authoring/schema/material", + name: "Typed reference material schema", + mimeType: "application/schema+json", + }, + { + uri: "video-compose://authoring/schema/generation-input", + name: "Generation input schema", + mimeType: "application/schema+json", + }, + ]; + if (rpc.method === "resources/list") return { resources }; + if (rpc.method === "resources/templates/list") + return { + resourceTemplates: [ + { + uriTemplate: + "video-compose://projects/{projectId}/authoring/workflow", + name: "Authorized project authoring workflow", + mimeType: "application/json", + }, + ], + }; + if (rpc.method === "resources/read") { + const uri = String(p.uri ?? ""); + let text: string, mimeType: string; + if (uri === resources[0].uri || uri === resources[1].uri) { + text = AUTHORING_RULES[uri.endsWith("/en") ? "en" : "zh"]; + mimeType = "text/plain"; + } else if (uri === resources[2].uri || uri === resources[3].uri) { + text = JSON.stringify( + uri.endsWith("/material") + ? MATERIAL_DOCUMENT_JSON_SCHEMA + : GENERATION_INPUT_JSON_SCHEMA, + ); + mimeType = "application/schema+json"; + } else { + const match = + /^video-compose:\/\/projects\/([^/]+)\/authoring\/workflow$/.exec(uri); + if (!match) throw new Error("Unknown authoring resource URI."); + text = JSON.stringify( + await domain.authoring.workflow(actor, decodeURIComponent(match[1])), + ); + mimeType = "application/json"; + } + return { contents: [{ uri, mimeType, text }] }; + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cd65781..635b5e9 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,10 +8,16 @@ import { } from "@video-compose/domain"; import { createApiApp } from "./app.ts"; import type { AccessAuthConfig } from "./auth.ts"; +import { + D1OAuthStore, + InMemoryOAuthStore, + type OAuthStore, +} from "./oauth.ts"; export * from "./auth.ts"; export * from "./mcp.ts"; export * from "./app.ts"; +export * from "./oauth.ts"; export interface WorkerEnv { DB?: CloudflareD1DatabaseLike; @@ -30,22 +36,27 @@ export function createWorkerHandler( return { async fetch(request: Request, env?: WorkerEnv): Promise { const url = new URL(request.url); - const isApiOrMcp = + const isWorkerRoute = url.pathname === "/mcp" || url.pathname === "/v1" || - url.pathname.startsWith("/v1/"); + url.pathname.startsWith("/v1/") || + url.pathname === "/oauth" || + url.pathname.startsWith("/oauth/") || + url.pathname.startsWith("/.well-known/"); - if (!isApiOrMcp && env?.ASSETS) { + if (!isWorkerRoute && env?.ASSETS) { return env.ASSETS.fetch(request); } let domain = defaultService; + let oauthStore: OAuthStore = new InMemoryOAuthStore(); if (env?.DB) { const bucket = env.MEDIA_BUCKET ? new R2ObjectBucketAdapter(env.MEDIA_BUCKET) : new InMemoryObjectBucket(); const store = new D1TransactionalStore(env.DB, bucket); domain = new VideoComposeDomainService(store); + oauthStore = new D1OAuthStore(env.DB); } let authConfig: AccessAuthConfig | undefined; @@ -61,7 +72,7 @@ export function createWorkerHandler( } } - const { app } = createApiApp(domain, authConfig); + const { app } = createApiApp(domain, authConfig, oauthStore); return app.fetch(request); }, }; diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts index 267465a..e1189ef 100644 --- a/apps/api/src/mcp.ts +++ b/apps/api/src/mcp.ts @@ -1,3 +1,5 @@ +import { AUTHORING_TOOLS, AUTHORING_SERVER_INSTRUCTIONS, WORKFLOW_CONTEXT_SCHEMA, authoringRpc } from "./authoring-mcp.ts"; +import type { WorkflowContext } from "@video-compose/protocol"; import { DomainError, type TypedOperation, @@ -31,34 +33,585 @@ export interface JsonRpcResponse { }; } -export const MCP_TOOLS_MANIFEST = [ - { name: "project_list", description: "List projects accessible to the caller" }, - { name: "project_get", description: "Get project details and member roles" }, - { name: "project_overview", description: "Get authorized project overview, composition list and shared resource counts" }, - { name: "project_create", description: "Create a new project" }, - { name: "composition_get", description: "Get composition revision and resolved subjects" }, - { name: "composition_create", description: "Create a new composition in a project" }, - { name: "composition_list", description: "List compositions accessible within a project" }, - { name: "composition_patch", description: "Apply ID-addressed typed patch to a composition" }, - { name: "composition_history", description: "List immutable revision and change history" }, - { name: "composition_restore", description: "Restore a historical composition revision" }, - { name: "composition_export", description: "Export portable composition bundle (JSON/YAML)" }, - { name: "composition_import", description: "Import portable composition bundle (JSON/YAML)" }, - { name: "asset_search", description: "Search immutable project assets" }, - { name: "asset_upload", description: "Upload and verify SHA-256 media asset" }, - { name: "asset_link", description: "Attach a candidate or selected asset to a shot" }, - { name: "asset_delete", description: "Delete an unreferenced asset" }, - { name: "subject_list", description: "List character and prop subjects in a project" }, - { name: "subject_get", description: "Get subject definition and specific revision snapshot" }, - { name: "subject_create", description: "Create a character or prop subject" }, - { name: "subject_patch", description: "Create a candidate subject revision with changes" }, - { name: "subject_reference_attach", description: "Attach a reference asset to a subject variant" }, - { name: "subject_approve_revision", description: "Promote a candidate subject revision to approved" }, - { name: "shot_subject_bind", description: "Pin a shot to a specific approved subject revision and variant" }, - { name: "subject_usage", description: "Inspect which compositions and shots bind a subject" }, - { name: "review_submit", description: "Submit a review proposal for a composition" }, - { name: "review_approve", description: "Approve and merge a review proposal" }, -] as const; +export interface McpToolDefinition { + name: string; + description: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + }; +} + +export const MCP_TOOLS_MANIFEST: McpToolDefinition[] = [ + ...AUTHORING_TOOLS, + { + name: "project_list", + description: "List projects accessible to the caller", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "project_get", + description: "Get project details and member roles", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + }, + required: ["projectId"], + }, + }, + { + name: "project_overview", + description: "Get authorized project overview, composition list and shared resource counts", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + }, + required: ["projectId"], + }, + }, + { + name: "project_create", + description: "Create a new project", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Unique project ID" }, + name: { type: "string", description: "Project display name" }, + description: { type: "string", description: "Project description" }, + members: { + type: "object", + description: "Map of actorId to role ('reader' | 'writer')", + }, + operationId: { type: "string", description: "Idempotency key" }, + }, + required: ["id", "name"], + }, + }, + { + name: "composition_get", + description: "Get composition revision and resolved subjects", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + revision: { + type: "number", + description: "Specific revision number (defaults to latest)", + }, + }, + required: ["projectId", "compositionId"], + }, + }, + { + name: "composition_create", + description: "Create a new composition in a project", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + document: { + type: "object", + description: "Complete declarative CompositionDocument", + }, + operationId: { type: "string", description: "Idempotency key" }, + }, + required: ["projectId", "document"], + }, + }, + { + name: "composition_list", + description: "List compositions accessible within a project", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + }, + required: ["projectId"], + }, + }, + { + name: "composition_patch", + description: "Apply ID-addressed typed patch to a composition", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + baseRevision: { + type: "number", + description: "Base revision number for optimistic concurrency", + }, + operationId: { type: "string", description: "Idempotency key" }, + operations: { + type: "array", + description: "List of typed patch operations", + items: { type: "object" }, + }, + message: { type: "string", description: "Commit / change message" }, + }, + required: [ + "projectId", + "compositionId", + "baseRevision", + "operationId", + "operations", + ], + }, + }, + { + name: "composition_history", + description: "List immutable revision and change history", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + }, + required: ["projectId", "compositionId"], + }, + }, + { + name: "composition_restore", + description: "Restore a historical composition revision", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + targetRevision: { + type: "number", + description: "Historical revision number to restore", + }, + operationId: { type: "string", description: "Idempotency key" }, + }, + required: [ + "projectId", + "compositionId", + "targetRevision", + "operationId", + ], + }, + }, + { + name: "composition_export", + description: "Export portable composition bundle (JSON/YAML)", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + revision: { + type: "number", + description: "Revision to export (defaults to latest)", + }, + format: { + type: "string", + enum: ["json", "yaml"], + description: "Serialization format", + }, + }, + required: ["projectId", "compositionId"], + }, + }, + { + name: "composition_import", + description: "Import portable composition bundle (JSON/YAML)", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + payload: { + type: "string", + description: "Portable bundle JSON or YAML text", + }, + format: { + type: "string", + enum: ["json", "yaml"], + description: "Payload format", + }, + targetCompositionId: { + type: "string", + description: "Optional composition ID to import as", + }, + operationId: { type: "string", description: "Idempotency key" }, + }, + required: ["projectId", "payload"], + }, + }, + { + name: "asset_search", + description: "Search immutable project assets", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + mimePrefix: { + type: "string", + description: "Filter by MIME prefix (e.g. 'image/', 'video/')", + }, + }, + required: ["projectId"], + }, + }, + { + name: "asset_upload", + description: "Upload and verify SHA-256 media asset", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + assetId: { type: "string", description: "Asset identifier" }, + contentBase64: { + type: "string", + description: "Base64 encoded binary data", + }, + mimeType: { type: "string", description: "MIME type" }, + expectedSha256: { + type: "string", + description: "Optional expected SHA-256 hex digest", + }, + width: { type: "number", description: "Width in pixels" }, + height: { type: "number", description: "Height in pixels" }, + durationFrames: { type: "number", description: "Duration in frames" }, + }, + required: ["projectId", "assetId", "contentBase64", "mimeType"], + }, + }, + { + name: "asset_link", + description: "Attach a candidate or selected asset to a shot (maximum 5 candidate images per shot)", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + baseRevision: { type: "number", description: "Base revision number" }, + operationId: { type: "string", description: "Idempotency key" }, + shotId: { type: "string", description: "Target shot ID" }, + assetId: { type: "string", description: "Asset ID to attach" }, + mediaKind: { + type: "string", + enum: ["images", "videos", "audios"], + description: "Media kind", + }, + role: { type: "string", description: "Role tag" }, + notes: { type: "string", description: "Notes on candidate" }, + }, + required: [ + "projectId", + "compositionId", + "baseRevision", + "operationId", + "shotId", + "assetId", + ], + }, + }, + { + name: "asset_unlink", + description: "Detach a candidate asset from a shot", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + baseRevision: { type: "number", description: "Base revision number" }, + operationId: { type: "string", description: "Idempotency key" }, + shotId: { type: "string", description: "Target shot ID" }, + assetId: { type: "string", description: "Asset ID to detach" }, + mediaKind: { + type: "string", + enum: ["images", "videos", "audios"], + description: "Media kind (default: images)", + }, + }, + required: [ + "projectId", + "compositionId", + "baseRevision", + "operationId", + "shotId", + "assetId", + ], + }, + }, + { + name: "asset_update_candidate", + description: "Update metadata (role, notes, actor) of a shot's candidate asset", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + baseRevision: { type: "number", description: "Base revision number" }, + operationId: { type: "string", description: "Idempotency key" }, + shotId: { type: "string", description: "Target shot ID" }, + assetId: { type: "string", description: "Target candidate asset ID" }, + mediaKind: { + type: "string", + enum: ["images", "videos", "audios"], + description: "Media kind (default: images)", + }, + role: { type: "string", description: "Role tag" }, + actor: { type: "string", description: "Actor or contributor identifier" }, + notes: { type: "string", description: "Notes on candidate" }, + }, + required: [ + "projectId", + "compositionId", + "baseRevision", + "operationId", + "shotId", + "assetId", + ], + }, + }, + { + name: "asset_delete", + description: "Delete an unreferenced asset from the project library", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + assetId: { type: "string", description: "Asset ID to delete" }, + }, + required: ["projectId", "assetId"], + }, + }, + { + name: "subject_list", + description: "List character and prop subjects in a project", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + kind: { + type: "string", + enum: ["character", "prop"], + description: "Filter by subject kind", + }, + }, + required: ["projectId"], + }, + }, + { + name: "subject_get", + description: "Get subject definition and specific revision snapshot", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + subjectId: { type: "string", description: "Subject ID" }, + revision: { + type: "number", + description: "Specific revision number (defaults to latest)", + }, + }, + required: ["projectId", "subjectId"], + }, + }, + { + name: "subject_create", + description: "Create a character or prop subject", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + id: { type: "string", description: "Subject ID" }, + kind: { + type: "string", + enum: ["character", "prop"], + description: "Subject kind", + }, + name: { type: "string", description: "Display name" }, + defaultVariant: { + type: "string", + description: "Default variant name", + }, + status: { + type: "string", + enum: ["candidate", "approved"], + description: "Initial status", + }, + definition: { + type: "object", + description: "Subject definition { description, invariants }", + }, + variants: { + type: "object", + description: "Map of variant definitions", + }, + }, + required: ["projectId", "id", "kind", "name", "definition", "variants"], + }, + }, + { + name: "subject_patch", + description: "Create a candidate subject revision with changes", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + subjectId: { type: "string", description: "Subject ID" }, + baseRevision: { type: "number", description: "Base revision number" }, + operationId: { type: "string", description: "Idempotency key" }, + changes: { + type: "object", + description: "Changes to apply to the subject", + }, + }, + required: [ + "projectId", + "subjectId", + "baseRevision", + "operationId", + "changes", + ], + }, + }, + { + name: "subject_reference_attach", + description: "Attach a reference asset to a subject variant", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + subjectId: { type: "string", description: "Subject ID" }, + baseRevision: { type: "number", description: "Base revision number" }, + operationId: { type: "string", description: "Idempotency key" }, + variant: { type: "string", description: "Target variant name" }, + entry: { + type: "object", + description: "Reference pack entry { asset, view, notes, status }", + }, + }, + required: [ + "projectId", + "subjectId", + "baseRevision", + "operationId", + "variant", + "entry", + ], + }, + }, + { + name: "subject_approve_revision", + description: "Promote a candidate subject revision to approved", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + subjectId: { type: "string", description: "Subject ID" }, + revision: { + type: "number", + description: "Revision number to approve", + }, + operationId: { type: "string", description: "Idempotency key" }, + }, + required: ["projectId", "subjectId", "revision", "operationId"], + }, + }, + { + name: "shot_subject_bind", + description: "Pin a shot to a specific approved subject revision and variant", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + baseRevision: { type: "number", description: "Base revision number" }, + operationId: { type: "string", description: "Idempotency key" }, + shotId: { type: "string", description: "Target shot ID" }, + binding: { + type: "object", + description: "Binding { subjectId, subjectRevision, variant }", + }, + }, + required: [ + "projectId", + "compositionId", + "baseRevision", + "operationId", + "shotId", + "binding", + ], + }, + }, + { + name: "subject_usage", + description: "Inspect which compositions and shots bind a subject", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + subjectId: { type: "string", description: "Subject ID" }, + revision: { + type: "number", + description: "Optional revision number", + }, + }, + required: ["projectId", "subjectId"], + }, + }, + { + name: "review_submit", + description: "Submit a review proposal for a composition", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + compositionId: { type: "string", description: "Target composition ID" }, + baseRevision: { type: "number", description: "Base revision number" }, + operationId: { type: "string", description: "Idempotency key" }, + description: { type: "string", description: "Proposal description" }, + operations: { + type: "array", + description: "Proposed operations", + items: { type: "object" }, + }, + }, + required: [ + "projectId", + "compositionId", + "baseRevision", + "operationId", + "description", + "operations", + ], + }, + }, + { + name: "review_approve", + description: "Approve and merge a review proposal", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Target project ID" }, + proposalId: { type: "string", description: "Proposal ID to approve" }, + operationId: { type: "string", description: "Idempotency key" }, + }, + required: ["projectId", "proposalId", "operationId"], + }, + }, +]; + +for (const tool of MCP_TOOLS_MANIFEST) { + if (["composition_create", "composition_patch", "composition_import", "shot_subject_bind", "subject_create", "subject_patch", "subject_reference_attach", "composition_restore", "subject_approve_revision", "asset_link", "asset_unlink", "asset_update_candidate", "review_approve"].includes(tool.name)) { + tool.inputSchema.properties.workflowContext = WORKFLOW_CONTEXT_SCHEMA; + tool.description += " In strict projects, read authoring_workflow_get first and provide workflowContext. Raw mixed boards cannot become canonical references."; + } + if (tool.name === "composition_import") tool.inputSchema.properties.baseRevision = { type: "integer", minimum: 0, description: "Required when importing into an existing strict composition." }; + if (tool.name === "composition_export") tool.inputSchema.properties.targetVersion = { enum: ["video-compose/v1", "video-compose/v2"], description: "v2 material dependencies cannot be dropped for v1 readers." }; + if (tool.name === "project_create") tool.inputSchema.properties.authoringMode = { enum: ["strict", "legacy"], description: "New projects default to strict. legacy is an explicit archive/compatibility mode." }; +} function decodeBase64Bytes(base64: string): Uint8Array { const binary = atob(base64); @@ -90,22 +643,51 @@ export async function handleMcpJsonRpc( try { if (rpc.method === "initialize") { + const requestedVersion = String( + (rpc.params as Record | undefined)?.protocolVersion ?? + "2024-11-05", + ); return { jsonrpc: "2.0", id, result: { - protocolVersion: "2025-03-26", + protocolVersion: ["2025-03-26", "2025-06-18", "2025-11-25"].includes(requestedVersion) ? requestedVersion : "2025-11-25", + instructions: AUTHORING_SERVER_INSTRUCTIONS, serverInfo: { name: "video-compose-mcp", version: "0.1.0", }, capabilities: { - tools: {}, + prompts: { listChanged: false }, + resources: { subscribe: false, listChanged: false }, + tools: { + listChanged: false, + }, }, }, }; } + if (rpc.method === "notifications/initialized") { + return { + jsonrpc: "2.0", + id: id ?? null, + }; + } + + if (rpc.method === "ping") { + return { + jsonrpc: "2.0", + id, + result: {}, + }; + } + + if (["prompts/list", "prompts/get", "resources/list", "resources/templates/list", "resources/read"].includes(rpc.method)) { + try { return { jsonrpc: "2.0", id, result: await authoringRpc(domain, actor, rpc) }; } + catch (err) { if (err instanceof DomainError) throw err; return { jsonrpc: "2.0", id, error: { code: -32602, message: err instanceof Error ? err.message : String(err) } }; } + } + if (rpc.method === "tools/list") { return { jsonrpc: "2.0", @@ -125,7 +707,10 @@ export async function handleMcpJsonRpc( id, result: { content: [{ type: "text", text: JSON.stringify(data) }], - structuredContent: data, + // MCP structuredContent must be an object; list results stay in text. + ...(data !== null && typeof data === "object" && !Array.isArray(data) + ? { structuredContent: data } + : {}), isError: false, }, }; @@ -171,6 +756,21 @@ async function dispatchMcpTool( toolName: string, args: Record, ): Promise { + const projectId = String(args.projectId ?? ""); + switch (toolName) { + case "authoring_dependencies_get": return domain.authoring.impact(actor, projectId, { id: String(args.id ?? ""), revision: Number(args.revision) }); + case "authoring_workflow_get": return domain.authoring.workflow(actor, projectId, args.language === "en" ? "en" : "zh"); + case "authoring_document_list": return domain.authoring.list(actor, projectId); + case "authoring_document_get": return domain.authoring.get(actor, projectId, String(args.id ?? ""), args.revision === undefined ? undefined : Number(args.revision)); + case "reference_plan_validate": return domain.authoring.validate(actor, projectId, args.document); + case "authoring_document_put": return domain.authoring.put(actor, args as unknown as Parameters[1]); + case "authoring_document_review": return domain.authoring.review(actor, args as unknown as Parameters[1]); + case "authoring_document_approve": return domain.authoring.approve(actor, args as unknown as Parameters[1]); + case "generation_inputs_prepare": return domain.authoring.prepare(actor, projectId, args.request, args.workflowContext as WorkflowContext | undefined); + case "generation_evidence_record": return domain.authoring.recordEvidence(actor, args as unknown as Parameters[1]); + case "generation_evidence_list": return domain.authoring.evidence(actor, projectId); + } + switch (toolName) { case "project_list": return domain.listProjects(actor); @@ -185,6 +785,7 @@ async function dispatchMcpTool( return domain.createProject(actor, { id: String(args.id ?? ""), name: String(args.name ?? ""), + authoringMode: args.authoringMode as "strict" | "legacy" | undefined, description: args.description ? String(args.description) : undefined, members: args.members as Record | undefined, operationId: args.operationId ? String(args.operationId) : undefined, @@ -203,6 +804,7 @@ async function dispatchMcpTool( case "composition_create": return domain.createComposition(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), document: args.document as CompositionDocument, operationId: args.operationId ? String(args.operationId) : undefined, @@ -210,6 +812,7 @@ async function dispatchMcpTool( case "composition_patch": return domain.patchComposition(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), compositionId: String(args.compositionId ?? ""), baseRevision: Number(args.baseRevision), @@ -227,6 +830,7 @@ async function dispatchMcpTool( case "composition_restore": return domain.restoreCompositionRevision(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), compositionId: String(args.compositionId ?? ""), targetRevision: Number(args.targetRevision), @@ -235,6 +839,7 @@ async function dispatchMcpTool( case "composition_export": return domain.exportCompositionBundle(actor, { + targetVersion: args.targetVersion === "video-compose/v1" ? "video-compose/v1" : undefined, projectId: String(args.projectId ?? ""), compositionId: String(args.compositionId ?? ""), revision: typeof args.revision === "number" ? args.revision : undefined, @@ -243,6 +848,8 @@ async function dispatchMcpTool( case "composition_import": return domain.importCompositionBundle(actor, { + baseRevision: typeof args.baseRevision === "number" ? args.baseRevision : undefined, + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), payload: String(args.payload ?? ""), format: (args.format === "yaml" ? "yaml" : "json") as "json" | "yaml", @@ -278,6 +885,7 @@ async function dispatchMcpTool( case "asset_link": return domain.patchComposition(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), compositionId: String(args.compositionId ?? ""), baseRevision: Number(args.baseRevision), @@ -286,11 +894,51 @@ async function dispatchMcpTool( { op: "attach_shot_candidate", shotId: String(args.shotId ?? ""), - mediaKind: (args.mediaKind ?? "videos") as "images" | "videos" | "audios", + mediaKind: (args.mediaKind ?? "images") as "images" | "videos" | "audios", candidate: { asset: String(args.assetId ?? ""), actor: actor.actorId, ...(args.role ? { role: String(args.role) } : {}), + ...(args.notes ? { notes: String(args.notes) } : {}), + }, + }, + ], + }); + + case "asset_unlink": + return domain.patchComposition(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, + projectId: String(args.projectId ?? ""), + compositionId: String(args.compositionId ?? ""), + baseRevision: Number(args.baseRevision), + operationId: String(args.operationId ?? ""), + operations: [ + { + op: "detach_shot_candidate", + shotId: String(args.shotId ?? ""), + mediaKind: (args.mediaKind ?? "images") as "images" | "videos" | "audios", + assetId: String(args.assetId ?? ""), + }, + ], + }); + + case "asset_update_candidate": + return domain.patchComposition(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, + projectId: String(args.projectId ?? ""), + compositionId: String(args.compositionId ?? ""), + baseRevision: Number(args.baseRevision), + operationId: String(args.operationId ?? ""), + operations: [ + { + op: "update_shot_candidate", + shotId: String(args.shotId ?? ""), + mediaKind: (args.mediaKind ?? "images") as "images" | "videos" | "audios", + assetId: String(args.assetId ?? ""), + candidate: { + ...(args.role !== undefined ? { role: String(args.role) } : {}), + ...(args.actor !== undefined ? { actor: String(args.actor) } : {}), + ...(args.notes !== undefined ? { notes: String(args.notes) } : {}), }, }, ], @@ -320,6 +968,7 @@ async function dispatchMcpTool( case "subject_create": return domain.createSubject(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), id: String(args.id ?? ""), kind: String(args.kind ?? "character") as SubjectKind, @@ -334,6 +983,7 @@ async function dispatchMcpTool( case "subject_patch": return domain.patchSubject(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), subjectId: String(args.subjectId ?? ""), baseRevision: Number(args.baseRevision), @@ -343,6 +993,7 @@ async function dispatchMcpTool( case "subject_reference_attach": return domain.attachSubjectReference(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), subjectId: String(args.subjectId ?? ""), baseRevision: Number(args.baseRevision), @@ -355,6 +1006,7 @@ async function dispatchMcpTool( case "subject_approve_revision": return domain.approveSubjectRevision(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), subjectId: String(args.subjectId ?? ""), revision: Number(args.revision), @@ -363,6 +1015,7 @@ async function dispatchMcpTool( case "shot_subject_bind": return domain.patchComposition(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), compositionId: String(args.compositionId ?? ""), baseRevision: Number(args.baseRevision), @@ -395,6 +1048,7 @@ async function dispatchMcpTool( case "review_approve": return domain.approveProposal(actor, { + workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), proposalId: String(args.proposalId ?? ""), operationId: String(args.operationId ?? ""), diff --git a/apps/api/src/oauth.ts b/apps/api/src/oauth.ts new file mode 100644 index 0000000..56f6939 --- /dev/null +++ b/apps/api/src/oauth.ts @@ -0,0 +1,648 @@ +import type { CloudflareD1DatabaseLike } from "@video-compose/domain"; + +export interface McpClient { + id: string; + name: string; + redirectUris: string[]; + grantTypes: string[]; + responseTypes: string[]; + scope: string; + authMethod: string; // "none" | "client_secret_post" | "client_secret_basic" + secretHash?: string; + owner?: string; + disabled: boolean; + createdAt: number; + lastUsedAt?: number; +} + +export interface McpGrant { + id: string; + clientId: string; + redirectUri: string; + owner: string; + scope: string; + state?: string; + resource?: string; + codeChallenge: string; + codeChallengeMethod: string; // "S256" + codeHash?: string; + csrf: string; + approved: boolean; + expiresAt: number; + createdAt: number; + usedAt?: number; +} + +export interface McpToken { + id: string; + clientId: string; + kind: "access" | "refresh"; + tokenHash: string; + scopes: string; + resource: string; + owner: string; + expiresAt: number; + revokedAt?: number; + createdAt: number; + lastUsedAt?: number; +} + +export interface OAuthStore { + getClient(id: string): Promise; + createClient(client: McpClient): Promise; + updateClient(client: McpClient): Promise; + createGrant(grant: McpGrant): Promise; + getGrant(id: string): Promise; + getGrantByCodeHash(codeHash: string): Promise; + updateGrant(grant: McpGrant): Promise; + createToken(token: McpToken): Promise; + getTokenByHash(tokenHash: string): Promise; + updateToken(token: McpToken): Promise; + revokeToken(tokenHash: string): Promise; +} + +export function base64Url(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export function randomToken(byteLength = 32): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return base64Url(bytes); +} + +export async function hashToken(token: string): Promise { + const encoded = new TextEncoder().encode(token); + const hash = await crypto.subtle.digest("SHA-256", encoded); + const hashArray = Array.from(new Uint8Array(hash)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function verifyPkce( + verifier: string, + challenge: string, + method = "S256", +): Promise { + if (method !== "S256") { + return false; + } + const encoded = new TextEncoder().encode(verifier); + const hash = await crypto.subtle.digest("SHA-256", encoded); + const calculatedChallenge = base64Url(hash); + return calculatedChallenge === challenge; +} + +export class InMemoryOAuthStore implements OAuthStore { + private clients = new Map(); + private grants = new Map(); + private grantsByCode = new Map(); + private tokens = new Map(); + + async getClient(id: string): Promise { + return this.clients.get(id) ?? null; + } + + async createClient(client: McpClient): Promise { + this.clients.set(client.id, { ...client }); + } + + async updateClient(client: McpClient): Promise { + this.clients.set(client.id, { ...client }); + } + + async createGrant(grant: McpGrant): Promise { + this.grants.set(grant.id, { ...grant }); + if (grant.codeHash) { + this.grantsByCode.set(grant.codeHash, { ...grant }); + } + } + + async getGrant(id: string): Promise { + return this.grants.get(id) ?? null; + } + + async getGrantByCodeHash(codeHash: string): Promise { + return this.grantsByCode.get(codeHash) ?? null; + } + + async updateGrant(grant: McpGrant): Promise { + this.grants.set(grant.id, { ...grant }); + if (grant.codeHash) { + this.grantsByCode.set(grant.codeHash, { ...grant }); + } + } + + async createToken(token: McpToken): Promise { + this.tokens.set(token.tokenHash, { ...token }); + } + + async getTokenByHash(tokenHash: string): Promise { + return this.tokens.get(tokenHash) ?? null; + } + + async updateToken(token: McpToken): Promise { + this.tokens.set(token.tokenHash, { ...token }); + } + + async revokeToken(tokenHash: string): Promise { + const token = this.tokens.get(tokenHash); + if (token) { + token.revokedAt = Date.now(); + this.tokens.set(tokenHash, token); + } + } +} + +export class D1OAuthStore implements OAuthStore { + private db: CloudflareD1DatabaseLike; + + constructor(db: CloudflareD1DatabaseLike) { + this.db = db; + } + + async getClient(id: string): Promise { + const res = await this.db + .prepare( + "SELECT id, name, redirect_uris_json, grant_types_json, response_types_json, scope, auth_method, secret_hash, owner, disabled, created_at, last_used_at FROM mcp_clients WHERE id = ?", + ) + .bind(id) + .all<{ + id: string; + name: string; + redirect_uris_json: string; + grant_types_json: string; + response_types_json: string; + scope: string; + auth_method: string; + secret_hash: string | null; + owner: string | null; + disabled: number; + created_at: number; + last_used_at: number | null; + }>(); + + const row = res.results?.[0]; + if (!row) return null; + return { + id: row.id, + name: row.name, + redirectUris: JSON.parse(row.redirect_uris_json), + grantTypes: JSON.parse(row.grant_types_json), + responseTypes: JSON.parse(row.response_types_json), + scope: row.scope, + authMethod: row.auth_method, + secretHash: row.secret_hash ?? undefined, + owner: row.owner ?? undefined, + disabled: row.disabled === 1, + createdAt: row.created_at, + lastUsedAt: row.last_used_at ?? undefined, + }; + } + + async createClient(client: McpClient): Promise { + await this.db + .prepare( + "INSERT INTO mcp_clients (id, name, redirect_uris_json, grant_types_json, response_types_json, scope, auth_method, secret_hash, owner, disabled, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind( + client.id, + client.name, + JSON.stringify(client.redirectUris), + JSON.stringify(client.grantTypes), + JSON.stringify(client.responseTypes), + client.scope, + client.authMethod, + client.secretHash ?? null, + client.owner ?? null, + client.disabled ? 1 : 0, + client.createdAt, + client.lastUsedAt ?? null, + ) + .run(); + } + + async updateClient(client: McpClient): Promise { + await this.db + .prepare( + "UPDATE mcp_clients SET name = ?, redirect_uris_json = ?, grant_types_json = ?, response_types_json = ?, scope = ?, auth_method = ?, secret_hash = ?, owner = ?, disabled = ?, last_used_at = ? WHERE id = ?", + ) + .bind( + client.name, + JSON.stringify(client.redirectUris), + JSON.stringify(client.grantTypes), + JSON.stringify(client.responseTypes), + client.scope, + client.authMethod, + client.secretHash ?? null, + client.owner ?? null, + client.disabled ? 1 : 0, + client.lastUsedAt ?? null, + client.id, + ) + .run(); + } + + async createGrant(grant: McpGrant): Promise { + await this.db + .prepare( + "INSERT INTO mcp_grants (id, client_id, redirect_uri, owner, scope, state, resource, code_challenge, code_challenge_method, code_hash, csrf, approved, expires_at, created_at, used_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind( + grant.id, + grant.clientId, + grant.redirectUri, + grant.owner, + grant.scope, + grant.state ?? null, + grant.resource ?? null, + grant.codeChallenge, + grant.codeChallengeMethod, + grant.codeHash ?? null, + grant.csrf, + grant.approved ? 1 : 0, + grant.expiresAt, + grant.createdAt, + grant.usedAt ?? null, + ) + .run(); + } + + async getGrant(id: string): Promise { + const res = await this.db + .prepare( + "SELECT id, client_id, redirect_uri, owner, scope, state, resource, code_challenge, code_challenge_method, code_hash, csrf, approved, expires_at, created_at, used_at FROM mcp_grants WHERE id = ?", + ) + .bind(id) + .all<{ + id: string; + client_id: string; + redirect_uri: string; + owner: string; + scope: string; + state: string | null; + resource: string | null; + code_challenge: string; + code_challenge_method: string; + code_hash: string | null; + csrf: string; + approved: number; + expires_at: number; + created_at: number; + used_at: number | null; + }>(); + + const row = res.results?.[0]; + if (!row) return null; + return { + id: row.id, + clientId: row.client_id, + redirectUri: row.redirect_uri, + owner: row.owner, + scope: row.scope, + state: row.state ?? undefined, + resource: row.resource ?? undefined, + codeChallenge: row.code_challenge, + codeChallengeMethod: row.code_challenge_method, + codeHash: row.code_hash ?? undefined, + csrf: row.csrf, + approved: row.approved === 1, + expiresAt: row.expires_at, + createdAt: row.created_at, + usedAt: row.used_at ?? undefined, + }; + } + + async getGrantByCodeHash(codeHash: string): Promise { + const res = await this.db + .prepare( + "SELECT id, client_id, redirect_uri, owner, scope, state, resource, code_challenge, code_challenge_method, code_hash, csrf, approved, expires_at, created_at, used_at FROM mcp_grants WHERE code_hash = ?", + ) + .bind(codeHash) + .all<{ + id: string; + client_id: string; + redirect_uri: string; + owner: string; + scope: string; + state: string | null; + resource: string | null; + code_challenge: string; + code_challenge_method: string; + code_hash: string | null; + csrf: string; + approved: number; + expires_at: number; + created_at: number; + used_at: number | null; + }>(); + + const row = res.results?.[0]; + if (!row) return null; + return { + id: row.id, + clientId: row.client_id, + redirectUri: row.redirect_uri, + owner: row.owner, + scope: row.scope, + state: row.state ?? undefined, + resource: row.resource ?? undefined, + codeChallenge: row.code_challenge, + codeChallengeMethod: row.code_challenge_method, + codeHash: row.code_hash ?? undefined, + csrf: row.csrf, + approved: row.approved === 1, + expiresAt: row.expires_at, + createdAt: row.created_at, + usedAt: row.used_at ?? undefined, + }; + } + + async updateGrant(grant: McpGrant): Promise { + await this.db + .prepare( + "UPDATE mcp_grants SET code_hash = ?, approved = ?, expires_at = ?, used_at = ? WHERE id = ?", + ) + .bind( + grant.codeHash ?? null, + grant.approved ? 1 : 0, + grant.expiresAt, + grant.usedAt ?? null, + grant.id, + ) + .run(); + } + + async createToken(token: McpToken): Promise { + await this.db + .prepare( + "INSERT INTO mcp_tokens (id, client_id, kind, token_hash, scopes, resource, owner, expires_at, revoked_at, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind( + token.id, + token.clientId, + token.kind, + token.tokenHash, + token.scopes, + token.resource, + token.owner, + token.expiresAt, + token.revokedAt ?? null, + token.createdAt, + token.lastUsedAt ?? null, + ) + .run(); + } + + async getTokenByHash(tokenHash: string): Promise { + const res = await this.db + .prepare( + "SELECT id, client_id, kind, token_hash, scopes, resource, owner, expires_at, revoked_at, created_at, last_used_at FROM mcp_tokens WHERE token_hash = ?", + ) + .bind(tokenHash) + .all<{ + id: string; + client_id: string; + kind: "access" | "refresh"; + token_hash: string; + scopes: string; + resource: string; + owner: string; + expires_at: number; + revoked_at: number | null; + created_at: number; + last_used_at: number | null; + }>(); + + const row = res.results?.[0]; + if (!row) return null; + return { + id: row.id, + clientId: row.client_id, + kind: row.kind, + tokenHash: row.token_hash, + scopes: row.scopes, + resource: row.resource, + owner: row.owner, + expiresAt: row.expires_at, + revokedAt: row.revoked_at ?? undefined, + createdAt: row.created_at, + lastUsedAt: row.last_used_at ?? undefined, + }; + } + + async updateToken(token: McpToken): Promise { + await this.db + .prepare( + "UPDATE mcp_tokens SET expires_at = ?, revoked_at = ?, last_used_at = ? WHERE token_hash = ?", + ) + .bind( + token.expiresAt, + token.revokedAt ?? null, + token.lastUsedAt ?? null, + token.tokenHash, + ) + .run(); + } + + async revokeToken(tokenHash: string): Promise { + await this.db + .prepare( + "UPDATE mcp_tokens SET revoked_at = ? WHERE token_hash = ?", + ) + .bind(Date.now(), tokenHash) + .run(); + } +} + +export function renderConsentPage(params: { + grantId: string; + clientName: string; + scope: string; + ownerEmail: string; + csrf: string; +}): string { + return ` + + + + + 授权 MCP 客户端 - Video Compose + + + +
+
Video Compose · MCP 授权
+

连接外部 Agent 客户端

+

客户端 ${escapeHtml(params.clientName)} 正在请求通过 Model Context Protocol 访问您的项目与编排工作区。

+ +
+
+ 登录身份 + ${escapeHtml(params.ownerEmail)} +
+
+ 授权范围 + ${escapeHtml(params.scope || "video-compose.all")} +
+
+ 协议版本 + OAuth 2.1 (PKCE S256) +
+
+ +
+ + +
+ + +
+
+
+ +`; +} + +export function renderSignInPage(loginUrl: string): string { + return ` + + + + 需要登录 - Video Compose + + + +
+

请先登录

+

需要先验证您的身份才能授权 MCP 客户端接入。

+ 前往 SSO 登录 +
+ +`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 8e07dd8..f9388ec 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -1,10 +1,14 @@ { - // Cloudflare Worker configuration for Video Compose (verified hostname: video-compose.talkincode.net) + // Cloudflare Worker configuration for Video Compose (canonical hostname: vflow.talkincode.net; legacy alias retained) "name": "video-compose", "main": "src/index.ts", "compatibility_date": "2026-10-01", "workers_dev": false, "routes": [ + { + "pattern": "vflow.talkincode.net", + "custom_domain": true + }, { "pattern": "video-compose.talkincode.net", "custom_domain": true @@ -14,7 +18,7 @@ "directory": "../web/dist", "binding": "ASSETS", "not_found_handling": "single-page-application", - "run_worker_first": ["/v1/*", "/mcp"] + "run_worker_first": ["/v1/*", "/mcp", "/oauth/*", "/.well-known/*"] }, "d1_databases": [ { diff --git a/apps/web/src/AuthoringPanel.tsx b/apps/web/src/AuthoringPanel.tsx new file mode 100644 index 0000000..b29650a --- /dev/null +++ b/apps/web/src/AuthoringPanel.tsx @@ -0,0 +1,1209 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import type { VideoComposeClient } from "@video-compose/client"; +import type { + AssetRecord, + SubjectRecord, + TypedOperation, +} from "@video-compose/domain"; +import { + AUTHORING_POLICY_VERSION, + AUTHORING_PROMPTS, + AUTHORING_VERSION, + REQUIRED_REVIEW_CHECKS, + MaterialDocumentSchema, + renderAuthoringPrompt, + type CompositionDocument, + type MaterialDocument, + type MaterialRecord, + type MaterialRevision, + type ReferencePack, + type ReferenceOwner, + type ReferencePurpose, + type GenerationInputPacket, + type GenerationEvidence, +} from "@video-compose/protocol"; +import { MediaPreview } from "./MediaPreview.tsx"; + +const categories: Record = { + moodboard: "原始 / 混合板(仅归档)", + character_face: "人物 · 面部", + character_body: "人物 · 身体与服装", + prop_views: "单道具 · 多角度", + prop_catalog: "多道具 · 逐格目录", + scene_views: "场景 · 多机位", + layout: "空间布局", + single_frame: "单帧候选", +}; +const json = (value: unknown) => JSON.stringify(value, null, 2); +const newId = () => crypto.randomUUID(); +function template(kind: string, owner?: ReferenceOwner): MaterialDocument { + const common = { + schemaVersion: AUTHORING_VERSION, + id: `reference-${newId()}`, + title: "新参考素材", + }; + if (kind === "scene") + return { + ...common, + kind, + geometry: "填写固定地形结构与动作轴线", + anchors: { + support_main: { + description: "填写角色所处的实际承托面", + supportSurface: true, + }, + }, + environmentVariants: { + base: { time: "待确认", weather: "待确认", state: "待确认环境状态" }, + }, + }; + if (kind === "storyboard_sequence") + return { + ...common, + kind, + compositionId: "", + compositionRevision: 1, + captionPolicy: "forbidden", + panels: [], + }; + return { + ...common, + kind: "reference_pack", + category: kind as ReferencePack["category"], + ...(owner && !["prop_catalog", "moodboard"].includes(kind) + ? { owner } + : {}), + views: [], + sources: [], + conflicts: [], + }; +} +interface Props { + client: VideoComposeClient; + projectId: string; + composition: CompositionDocument; + compositionRevision: number; + assets: AssetRecord[]; + subjects: SubjectRecord[]; + activeShotId: string | null; + disabled: boolean; + active: boolean; + onDirtyChange?: (dirty: boolean) => void; + patch: (operations: TypedOperation[], message: string) => Promise; +} +export function AuthoringPanel({ + client, + projectId, + composition, + compositionRevision, + assets, + subjects, + activeShotId, + disabled, + active, + onDirtyChange, + patch, +}: Props): React.ReactElement { + const [workflow, setWorkflow] = + useState>>(); + const [records, setRecords] = useState([]); + const [current, setCurrent] = useState(); + const [draft, setDraft] = useState(""); + const [category, setCategory] = useState("character_face"); + const [ownerKey, setOwnerKey] = useState(""); + const [selected, setSelected] = useState(""); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [busy, setBusy] = useState(false); + const [checks, setChecks] = useState< + Record + >({}); + const [evidence, setEvidence] = useState(""); + const [decision, setDecision] = useState<"accepted" | "rejected" | "unknown">( + "unknown", + ); + const [promptName, setPromptName] = useState(AUTHORING_PROMPTS[0].name); + const [brief, setBrief] = useState(""); + const [prompt, setPrompt] = useState(""); + const [request, setRequest] = useState(""); + const [packet, setPacket] = useState(); + const [reports, setReports] = useState([]); + const [actual, setActual] = useState("[]"); + const [outputs, setOutputs] = useState(""); + const [executionNote, setExecutionNote] = useState(""); + const [method, setMethod] = + useState("manual_unknown"); + const operation = useRef(newId()); + const lock = useRef(false); + const reload = useCallback(async () => { + const [w, r, e] = await Promise.all([ + client.getAuthoringWorkflow(projectId), + client.listAuthoringDocuments(projectId), + client.listGenerationEvidence(projectId), + ]); + setWorkflow(w); + setRecords(r); + setReports(e); + }, [client, projectId]); + useEffect(() => { + if (active) void reload().catch((e) => setError(String(e.message ?? e))); + }, [active, reload]); + const run = async (fn: () => Promise) => { + if (lock.current) return; + lock.current = true; + setBusy(true); + setError(""); + setNotice(""); + try { + await fn(); + operation.current = newId(); + } catch (e) { + setError(`${e instanceof Error ? e.message : String(e)}(草稿已保留)`); + } finally { + lock.current = false; + setBusy(false); + } + }; + const parsed = MaterialDocumentSchema.safeParse( + draft + ? (() => { + try { + return JSON.parse(draft); + } catch { + return null; + } + })() + : null, + ); + const doc = parsed.success ? parsed.data : undefined; + const items = + doc?.kind === "reference_pack" + ? doc.views + : doc?.kind === "storyboard_sequence" + ? doc.panels + : []; + const item = items.find((i) => i.id === selected); + const required = + doc && doc.kind !== "policy" + ? REQUIRED_REVIEW_CHECKS[ + doc.kind === "reference_pack" ? doc.category : doc.kind + ] + : []; + const owners: ReferenceOwner[] = subjects.flatMap((s) => + Object.values(s.revisions).flatMap((r) => + Object.keys(r.variants).map((variant) => ({ + kind: r.kind, + id: r.id, + revision: r.revision, + variant, + })), + ), + ); + records.forEach((r) => + Object.values(r.revisions).forEach((rev) => { + if (rev.document.kind === "scene") + Object.keys(rev.document.environmentVariants).forEach((variant) => + owners.push({ + kind: "scene", + id: r.id, + revision: rev.revision, + variant, + }), + ); + }), + ); + const ownerValue = (o: ReferenceOwner) => + `${o.kind}:${o.id}@${o.revision}/${o.variant}`; + const chosenOwner = owners.find((o) => ownerValue(o) === ownerKey); + const update = (next: MaterialDocument) => { + setDraft(json(next)); + setPacket(undefined); + }; + const choose = (id: string) => { + setSelected(id); + setChecks({}); + setEvidence(""); + setDecision("unknown"); + }; + const load = (rev: MaterialRevision) => { + setCurrent(rev); + update(rev.document); + choose(""); + }; + const showAsset = (id?: string) => { + const a = assets.find((a) => a.id === id); + return a ? ( + + ) : ( +

尚未选择独立图片

+ ); + }; + const mutateItem = (change: Record) => { + if ( + !doc || + (doc.kind !== "reference_pack" && doc.kind !== "storyboard_sequence") + ) + return; + const next = structuredClone(doc); + const entries = next.kind === "reference_pack" ? next.views : next.panels; + const entry = entries.find((i) => i.id === selected); + if (entry) { + Object.assign(entry, change); + delete entry.review; + update(next); + } + }; + const saved = current && draft === json(current.document); + useEffect(() => { + onDirtyChange?.( + !disabled && + ((!!draft && draft !== json(current?.document)) || + !!evidence || + Object.keys(checks).length > 0), + ); + }, [draft, current, evidence, checks, disabled, onDirtyChange]); + return ( +
+
+
+

+ REFERENCE WORKFLOW ·{" "} + {workflow?.mode === "strict" ? "严格制作" : "旧项目兼容"} +

+

先把参考说清楚,再进入镜头

+

+ 身份、服装、道具和场景分别固定。描述与审阅放在图外,视频只接收明确选中的独立图片。 +

+
+ +
+ {error && ( +

+ {error} +

+ )} + {notice &&

{notice}

} +
+ 制作规则与 Agent 提示模板 +
{workflow?.rules}
+ +