From e7be5af3f27facba8d39cb98835fbe3ef13591c2 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 1 Oct 2026 22:58:20 +0800 Subject: [PATCH] feat(video-canvas): add TVstar creation and session workflows --- .gitignore | 1 + app/api/canvas/[id]/tvstar-sessions/route.ts | 57 ++ .../[id]/tvstar-sessions/share/route.ts | 58 ++ app/api/tvstar/plugin/download/route.ts | 35 + app/tvstar/plugin/page.tsx | 83 ++ app/tvstar/share/[token]/page.tsx | 41 + .../canvas/LiblibVideoCanvasReplica.tsx | 445 +++++++++- .../canvas/tvstar/CharacterStudioPanel.tsx | 144 +++ components/canvas/tvstar/TVstarPanel.tsx | 824 ++++++++++++++++++ .../canvas/tvstar/creativeAnalysisClient.ts | 56 ++ .../canvas/tvstar/generation-references.ts | 20 + components/canvas/tvstar/session-context.ts | 54 ++ ...o-canvas-liblib-parity-audit-2026-09-24.md | 61 ++ .../migrations/0103_add_tvstar_sessions.sql | 15 + lib/db/migrations/meta/_journal.json | 7 + lib/db/schema.ts | 23 + lib/video-canvas/group-execution.ts | 12 +- lib/video-canvas/smart-edit/contract.ts | 21 +- lib/video-canvas/smart-edit/execute.ts | 53 +- lib/video-canvas/smart-edit/highlights.ts | 77 ++ lib/video-canvas/smart-edit/semantic.ts | 2 +- lib/video-canvas/task-worker.ts | 14 +- lib/video-canvas/tvstar-session.ts | 71 ++ lib/video-canvas/tvstar-timing.ts | 24 + .../workflow-analysis-response.ts | 9 +- playwright.helpers.config.ts | 2 + proxy.ts | 1 + services/smart-edit/README.md | 1 + tests/routes/tvstar-creative-analysis.test.ts | 90 ++ tests/routes/tvstar-session.test.ts | 30 + .../video-canvas-groups-upscale.test.ts | 43 + .../video-canvas-model-matching.test.ts | 9 + tests/routes/video-canvas-smart-edit.test.ts | 40 + tsconfig.json | 2 +- 34 files changed, 2370 insertions(+), 55 deletions(-) create mode 100644 app/api/canvas/[id]/tvstar-sessions/route.ts create mode 100644 app/api/canvas/[id]/tvstar-sessions/share/route.ts create mode 100644 app/api/tvstar/plugin/download/route.ts create mode 100644 app/tvstar/plugin/page.tsx create mode 100644 app/tvstar/share/[token]/page.tsx create mode 100644 components/canvas/tvstar/CharacterStudioPanel.tsx create mode 100644 components/canvas/tvstar/TVstarPanel.tsx create mode 100644 components/canvas/tvstar/creativeAnalysisClient.ts create mode 100644 components/canvas/tvstar/generation-references.ts create mode 100644 components/canvas/tvstar/session-context.ts create mode 100644 lib/db/migrations/0103_add_tvstar_sessions.sql create mode 100644 lib/video-canvas/smart-edit/highlights.ts create mode 100644 lib/video-canvas/tvstar-session.ts create mode 100644 lib/video-canvas/tvstar-timing.ts create mode 100644 tests/routes/tvstar-creative-analysis.test.ts create mode 100644 tests/routes/tvstar-session.test.ts diff --git a/.gitignore b/.gitignore index ab3901e3c..d8889c0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,7 @@ services/smart-edit/__pycache__/ # Local verification artefacts (also excluded from Tailwind's automatic source scan). .qa/ .next-video-*/ +.next-tvstar-*/ services/video-upscale/dist/ services/video-upscale/__pycache__/ diff --git a/app/api/canvas/[id]/tvstar-sessions/route.ts b/app/api/canvas/[id]/tvstar-sessions/route.ts new file mode 100644 index 000000000..751fb9741 --- /dev/null +++ b/app/api/canvas/[id]/tvstar-sessions/route.ts @@ -0,0 +1,57 @@ +import { and, desc, eq, lte } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { db } from "@/lib/db"; +import { tvstarSession } from "@/lib/db/schema"; +import { ownsTaskProject } from "@/lib/video-canvas/task-store"; +import { tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session"; + +export const dynamic = "force-dynamic"; + +async function authorize(projectId: string) { + const session = await auth(); + if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) }; + if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) { + return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) }; + } + return { userId: session.user.id }; +} + +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const access = await authorize(id); + if (access.error) return access.error; + const rows = await db.select({ snapshot: tvstarSession.snapshot, shareToken: tvstarSession.shareToken }) + .from(tvstarSession) + .where(and(eq(tvstarSession.userId, access.userId!), eq(tvstarSession.projectId, id))) + .orderBy(desc(tvstarSession.clientUpdatedAt)).limit(20); + return NextResponse.json({ sessions: rows.map(({ snapshot, shareToken }) => ({ + ...(snapshot as Record), shareToken: shareToken || undefined, + })) }, { headers: { "Cache-Control": "no-store" } }); +} + +export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const access = await authorize(id); + if (access.error) return access.error; + const raw = await request.text(); + if (raw.length > 900_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 }); + let body: unknown; + try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); } + const parsed = z.object({ sessions: z.array(tvstarSessionSchema).max(20) }).safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 }); + for (const session of parsed.data.sessions) { + const clientUpdatedAt = new Date(Math.min(session.updatedAt, Date.now() + 60_000)); + await db.insert(tvstarSession).values({ + id: session.id, projectId: id, userId: access.userId!, title: session.title, + snapshot: session, clientUpdatedAt, + }).onConflictDoNothing({ target: tvstarSession.id }); + await db.update(tvstarSession).set({ title: session.title, snapshot: session, clientUpdatedAt, updatedAt: new Date() }) + .where(and( + eq(tvstarSession.id, session.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!), + lte(tvstarSession.clientUpdatedAt, clientUpdatedAt), + )); + } + return NextResponse.json({ ok: true }); +} diff --git a/app/api/canvas/[id]/tvstar-sessions/share/route.ts b/app/api/canvas/[id]/tvstar-sessions/share/route.ts new file mode 100644 index 000000000..6737b109c --- /dev/null +++ b/app/api/canvas/[id]/tvstar-sessions/share/route.ts @@ -0,0 +1,58 @@ +import { randomBytes } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { db } from "@/lib/db"; +import { tvstarSession } from "@/lib/db/schema"; +import { ownsTaskProject } from "@/lib/video-canvas/task-store"; +import { publicTVstarSnapshot, tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session"; + +export const dynamic = "force-dynamic"; + +async function owner(projectId: string) { + const session = await auth(); + if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) }; + if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) { + return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) }; + } + return { userId: session.user.id }; +} + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const access = await owner(id); + if (access.error) return access.error; + const raw = await request.text(); + if (raw.length > 300_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 }); + let body: unknown; + try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); } + const parsed = z.object({ session: tvstarSessionSchema }).safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 }); + const current = parsed.data.session; + const condition = and(eq(tvstarSession.id, current.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!)); + await db.insert(tvstarSession).values({ + id: current.id, projectId: id, userId: access.userId!, title: current.title, + snapshot: current, clientUpdatedAt: new Date(Math.min(current.updatedAt, Date.now() + 60_000)), + }).onConflictDoNothing({ target: tvstarSession.id }); + const existing = (await db.select({ shareToken: tvstarSession.shareToken }).from(tvstarSession).where(condition).limit(1))[0]; + if (!existing) return NextResponse.json({ error: "对话不存在" }, { status: 404 }); + const token = existing.shareToken || randomBytes(24).toString("base64url"); + if (!existing.shareToken) { + await db.update(tvstarSession).set({ + shareToken: token, sharedSnapshot: publicTVstarSnapshot(current), sharedAt: new Date(), updatedAt: new Date(), + }).where(condition); + } + return NextResponse.json({ url: `/tvstar/share/${token}`, token }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const access = await owner(id); + if (access.error) return access.error; + const sessionId = new URL(request.url).searchParams.get("sessionId") || ""; + if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "对话 ID 无效" }, { status: 400 }); + await db.update(tvstarSession).set({ shareToken: null, sharedSnapshot: null, sharedAt: null, updatedAt: new Date() }) + .where(and(eq(tvstarSession.id, sessionId), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!))); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/tvstar/plugin/download/route.ts b/app/api/tvstar/plugin/download/route.ts new file mode 100644 index 000000000..6a5476938 --- /dev/null +++ b/app/api/tvstar/plugin/download/route.ts @@ -0,0 +1,35 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import JSZip from "jszip"; +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; + +export const runtime = "nodejs"; + +const packageFiles = [ + "package.json", "package-lock.json", "api-client.mjs", "cli.mjs", "mcp-server.mjs", + "yedir-canvas/SKILL.md", "yedir-canvas/agents/openai.yaml", + "yedir-canvas/references/operation-protocol.md", +]; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id || session.user.type === "guest") { + return NextResponse.json({ error: "请先登录正式账号" }, { status: 401 }); + } + const zip = new JSZip(); + const base = path.join(process.cwd(), "integrations", "openclaw"); + try { + await Promise.all(packageFiles.map(async file => { + zip.file(`tvstar-mcp/${file}`, await readFile(path.join(base, file))); + })); + const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); + return new NextResponse(archive, { headers: { + "Content-Type": "application/zip", + "Content-Disposition": 'attachment; filename="tvstar-mcp.zip"', + "Cache-Control": "private, no-store", + } }); + } catch { + return NextResponse.json({ error: "插件安装包暂不可用" }, { status: 503 }); + } +} diff --git a/app/tvstar/plugin/page.tsx b/app/tvstar/plugin/page.tsx new file mode 100644 index 000000000..1a7c02eae --- /dev/null +++ b/app/tvstar/plugin/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +type KeyItem = { id: string; name: string; tokenPrefix: string; scopes: string[]; revokedAt: string | null; expiresAt: string | null }; + +export default function TVstarPluginPage() { + const [keys, setKeys] = useState([]); + const [createdToken, setCreatedToken] = useState(""); + const [busy, setBusy] = useState(false); + const [origin, setOrigin] = useState("https://ai.yydd.ai"); + useEffect(() => { setOrigin(window.location.origin); }, []); + const loadKeys = useCallback(async () => { + const response = await fetch("/api/user/access-keys", { cache: "no-store" }); + const data = await response.json(); + if (!response.ok || !data.success) throw new Error(data.error || "读取密钥失败"); + setKeys(Array.isArray(data.data) ? data.data : []); + }, []); + useEffect(() => { void loadKeys().catch(error => toast.error(error.message)); }, [loadKeys]); + + const createKey = async () => { + setBusy(true); + try { + const response = await fetch("/api/user/access-keys", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "TVstar MCP", scopes: ["canvas:read", "canvas:write"], expiresInDays: 90 }) }); + const data = await response.json(); + if (!response.ok || !data.success || !data.data?.token) throw new Error(data.error || "创建密钥失败"); + setCreatedToken(data.data.token); + await loadKeys(); + toast.success("密钥已创建;请立即复制,关闭后不会再次显示"); + } catch (error) { toast.error(error instanceof Error ? error.message : "创建密钥失败"); } + finally { setBusy(false); } + }; + const revokeKey = async (id: string) => { + if (!window.confirm("撤销后使用此密钥的 Agent 将失去画布访问权限,确定继续吗?")) return; + setBusy(true); + try { + const response = await fetch(`/api/user/access-keys/${id}`, { method: "DELETE" }); + const data = await response.json(); + if (!response.ok || !data.success) throw new Error(data.error || "撤销失败"); + setCreatedToken(""); + await loadKeys(); + toast.success("密钥已撤销"); + } catch (error) { toast.error(error instanceof Error ? error.message : "撤销失败"); } + finally { setBusy(false); } + }; + + const example = JSON.stringify({ mcpServers: { tvstar: { command: "node", args: ["/替换为解压目录/tvstar-mcp/mcp-server.mjs"], + env: { YEDIR_BASE_URL: origin, YEDIR_ACCESS_TOKEN: "粘贴新创建的 Access Key" } } } }, null, 2); + + return
+
+

TVstar Plugin

+

让 Agent 操作视频画布

+

当前插件提供画布列表、读取和有版本保护的节点操作。视频生成与合成仍由 TVstar 画布内的任务系统执行,MCP 暂未提供直接发起生成的工具。

+
+
+

1. 下载并安装

+

需要 Node.js 20 或更新版本。解压后在 tvstar-mcp 目录运行:

+ 下载 TVstar MCP 安装包 +
cd tvstar-mcp{"\n"}npm ci
+
+
+

2. 创建画布 Access Key

+

读取和修改画布需要对应权限。密钥只在创建时显示一次。

+ + {createdToken ?

{createdToken}

: null} +
+
+
+

3. 配置支持 STDIO MCP 的 Agent

+

将安装包的绝对路径和密钥填入客户端配置。不同 Agent 的配置文件位置和外层字段可能不同,请以所用客户端的说明为准。

+
{example}
+ +
+
+

已创建的 Access Key

+
{keys.length ? keys.map(key =>
{key.name} · {key.tokenPrefix} · {key.revokedAt ? "已撤销" : "有效"}{!key.revokedAt ? : null}
) :

暂无密钥

}
+
+
+
; +} diff --git a/app/tvstar/share/[token]/page.tsx b/app/tvstar/share/[token]/page.tsx new file mode 100644 index 000000000..99dc1a280 --- /dev/null +++ b/app/tvstar/share/[token]/page.tsx @@ -0,0 +1,41 @@ +import { eq } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { db } from "@/lib/db"; +import { tvstarSession } from "@/lib/db/schema"; + +export const dynamic = "force-dynamic"; + +type SharedSnapshot = { + title: string; + goal: string; + createdAt: number; + messages: { role: "user" | "assistant"; content: string; createdAt: number }[]; + plan: { summary: string; deliverables: string[] } | null; + shots: { shot: string; duration: string; sceneDescription: string }[]; +}; + +export default async function TVstarSharedConversation({ params }: { params: Promise<{ token: string }> }) { + const { token } = await params; + if (!/^[A-Za-z0-9_-]{32}$/.test(token || "")) notFound(); + const row = (await db.select({ snapshot: tvstarSession.sharedSnapshot }).from(tvstarSession) + .where(eq(tvstarSession.shareToken, token)).limit(1))[0]; + if (!row?.snapshot) notFound(); + const shared = row.snapshot as SharedSnapshot; + return
+
+

TVstar · 只读分享

+

{shared.title}

+

{new Date(shared.createdAt).toLocaleString("zh-CN")}

+

{shared.goal}

+
+ {(shared.messages || []).map((message, index) =>
+

{message.role === "user" ? "用户" : "TVstar"}

+

{message.content}

+
)} +
+ {shared.plan ?

创作规划

{shared.plan.summary}

: null} + {shared.shots?.length ?

分镜

    {shared.shots.map((shot, index) =>
  1. {shot.shot} · {shot.duration}

    {shot.sceneDescription}

  2. )}
: null} +

此页面是分享时的对话快照,无法编辑画布;对话正文中的文字和链接会原样显示。

+
+
; +} diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 914c08966..7336c0b93 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -12,6 +12,10 @@ import { SmartEditDialog } from "./smart-edit/SmartEditDialog"; import { SmartEditNodeCard, SmartEditNodeComposer, type SmartEditMode } from "./smart-edit/SmartEditNode"; import { smartEditRequestSchema, type EditPlan } from "@/lib/video-canvas/smart-edit/contract"; import { AudioGenerationComposer } from "./AudioGenerationComposer"; +import { TVstarPanel, type TVstarGenerationModel, type TVstarGenerationSelection, type TVstarModel, type TVstarNodeContext, type TVstarReference, type TVstarShot } from "./tvstar/TVstarPanel"; +import { resolveTVstarGenerationReferences } from "./tvstar/generation-references"; +import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing"; +import { CharacterStudioPanel, type StudioCharacter, type StudioView } from "./tvstar/CharacterStudioPanel"; import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice"; import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story"; import dynamic from "next/dynamic"; @@ -25,7 +29,7 @@ const DirectorStudioDialog = dynamic( ); import { directorMotionPrompt, resizeDirectorMotion } from "@/lib/video-canvas/director-motion"; import type { DirectorSnapshot } from "@/lib/video-canvas/director-scene"; -import { chooseStoryVideoSettings, filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, type CanvasModelKind } from "@/lib/video-canvas/model-matching"; +import { chooseStoryVideoSettings, filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, isCanvasLanguageModel, isCanvasVisionModel, type CanvasModelKind } from "@/lib/video-canvas/model-matching"; import { ScanText, @@ -67,6 +71,7 @@ import { Scissors, Settings2, SlidersHorizontal, + Smile, Sparkles, Star, Trash2, @@ -153,6 +158,8 @@ type ActivePanel = | "historyPicker" | "help" | "roleLibrary" + | "characterStudio" + | "tvstar" | "videoRoleLibrary" | "shortcuts" | "styleLibrary" @@ -1045,7 +1052,7 @@ const TOOLBAR_ITEMS: Array<{ id: Exclude; label: string; icon { id: "add", label: "添加节点", icon: Plus }, { id: "workflow", label: "工具箱", icon: GitBranch }, { id: "assets", label: "素材库", icon: Layers }, - { id: "roleLibrary", label: "角色库", icon: UserRound }, + { id: "characterStudio", label: "角色造型室", icon: Smile }, { id: "history", label: "历史记录", icon: Clock3 }, { id: "shortcuts", label: "快捷键", icon: Keyboard }, { id: "tutorial", label: "教程", icon: BookOpen }, @@ -4605,6 +4612,30 @@ export function LiblibVideoCanvasReplica({ const [draftTitle, setDraftTitle] = useState("未命名"); const [isTitleEditing, setIsTitleEditing] = useState(false); const [activePanel, setActivePanel] = useState(null); + const [tvstarDocked, setTvstarDocked] = useState(false); + const [tvstarDockWidth, setTvstarDockWidth] = useState(360); + useEffect(() => { + try { + setTvstarDocked(localStorage.getItem(`tvstar:docked:${canvasId}`) === "1"); + const savedWidth = Number(localStorage.getItem(`tvstar:dock-width:${canvasId}`)); + setTvstarDockWidth(Number.isFinite(savedWidth) && savedWidth >= 320 && savedWidth <= 560 ? savedWidth : 360); + } catch { /* storage is optional */ } + }, [canvasId]); + const tvstarPanelRestoreRef = useRef(null); + useEffect(() => { + tvstarPanelRestoreRef.current = canvasId; + try { + setActivePanel(localStorage.getItem(`tvstar:panel:${canvasId}`) === "open" ? "tvstar" : null); + } catch { /* storage is optional */ } + }, [canvasId]); + useEffect(() => { + if (tvstarPanelRestoreRef.current === canvasId) { + tvstarPanelRestoreRef.current = null; + return; + } + try { localStorage.setItem(`tvstar:panel:${canvasId}`, activePanel === "tvstar" ? "open" : "closed"); } + catch { /* storage is optional */ } + }, [activePanel, canvasId]); const [canvasViewMode, setCanvasViewMode] = useState("workflow"); const [assetManagerWidth, setAssetManagerWidth] = useState(ASSET_MANAGER_DEFAULT_WIDTH); const [historyListDownloadPendingId, setHistoryListDownloadPendingId] = useState(null); @@ -5484,6 +5515,326 @@ export function LiblibVideoCanvasReplica({ }; }, []); + const tvstarReferences = useMemo(() => nodes.flatMap((node) => { + const url = getNodeReferenceMediaUrl(node); + if (!url || (node.inputType !== "image" && node.inputType !== "video")) return []; + return [{ id: node.id, title: node.title, url, type: node.inputType, + duration: Number(node.draftValues?.duration) || undefined }]; + }), [nodes]); + const tvstarCanvasNodes = useMemo(() => nodes.filter(node => !isCanvasGroup(node)).map(node => { + const draft = node.draftValues || {}; + const content = Array.isArray(draft.content) ? draft.content.filter((value): value is string => typeof value === "string").join("\n") : ""; + const description = [draft.prompt, content, draft.sceneDescription].filter((value): value is string => typeof value === "string" && Boolean(value.trim())).join("\n"); + return { id: node.id, title: node.title, type: node.inputType || node.kind, description: description.slice(0, 1500) }; + }), [nodes]); + const tvstarModels = useMemo(() => canvasModelOptions + .filter(model => !model.isSubModel && isCanvasLanguageModel(model)) + .map(model => ({ id: model.id, label: model.displayName || model.modelId || model.id, modelId: model.modelId || "", providerType: model.providerType || "", supportsVision: isCanvasVisionModel(model) })), [canvasModelOptions]); + const tvstarGenerationModels = useMemo(() => canvasModelOptions + .filter(model => !model.isSubModel) + .flatMap(model => { + const label = model.displayName || model.modelId || model.id; + return [ + ...(filterCanvasModels([model], "image").length ? [{ id: model.id, label, kind: "image" as const, + supportsReference: filterCanvasModels([model], "image-edit").length > 0 }] : []), + ...(filterCanvasModels([model], "video", "first-frame").length ? [{ id: model.id, label, kind: "video" as const }] : []), + ]; + }), [canvasModelOptions]); + + const compileCanvasGroupRun = useCallback((sourceNodes: CloneNode[], sourceEdges: CloneEdge[], groupId: string) => + compileGroupRun(sourceNodes, sourceEdges, groupId, canvasId, n => { + const node = n as CloneNode, d = node.draftValues || {}, p = getNodeParams(node), settings = (p.settings || {}) as Record; + if (d.action === "VIDEO_HD_UPSCALE") return { kind: "video-upscale", params: { resolution: d.hdResolution || "1080P" } }; + if (d.audioParams) return { kind: "audio", params: d.audioParams as Record }; + if (getTargetModuleKind(node) === "smart-edit" || d.nodeType === "smart-edit") { + const draft = (d.smartEditDraft || {}) as any, plan = draft.plans?.[draft.selectedPlanIndex || 0] || d.editPlan; + const changes = sourceEdges.some(e => e.target === node.id && groupMembers(sourceNodes, groupId).some(m => m.id === e.source && (/GENERATE|UPSCALE/.test(String(m.draftValues?.action)) || m.draftValues?.generationTaskId))); + if (plan && !changes) return { kind: "smart-edit", params: { operation: "render", plan } }; + return { kind: "smart-edit", params: { operation: "plan", autoRender: true, mode: draft.mode || "sequence", targetDuration: draft.targetDuration || 300, shotDurations: draft.shotDurations, shotDescriptions: draft.shotDescriptions, ratio: draft.ratio || "16:9", resolution: draft.resolution || "720p", semantic: draft.semantic ?? false, preferredModelRef: draft.preferredModelRef, prompt: draft.prompt || "", removeSilence: draft.removeSilence || false, transcribe: draft.transcribe || false, music: draft.music, beatSync: draft.beatSync || false, variants: 1 } }; + } + const key = String(p.functionKey || d.functionKey || (node.inputType === "video" ? "text-to-video" : node.inputType === "image" ? "" : "story-script")); + const definition = key ? getConfiguredFunction(videoCanvasFunctionConfig, key) : { key: "image-generate", kind: "image-edit" as const, route: "canvas-generate-image" as const, enabled: true, label: "图片生成", prompt: "", description: "" }; + if (!definition?.enabled || !videoCanvasFunctionConfig.enabled) throw new Error(`${node.title}:功能未启用或未配置`); + const model = getFunctionModel(videoCanvasFunctionConfig, definition, normalizeModelRef(settings.model || p.model)); + const prompt = composeFunctionPrompt(definition, String(p.prompt || d.prompt || "")); + if (node.inputType === "video") return { kind: "video", params: { prompt, preferredModelRef: model, mode: p.mode || settings.mode || (sourceEdges.some(e => e.target === node.id && sourceNodes.find(n => n.id === e.source)?.inputType === "image") ? "first-frame" : "text-to-video"), duration: Number(String(settings.duration || p.duration || 5).replace(/s$/, "")), ratio: settings.ratio || p.ratio || "16:9", resolution: settings.resolution || p.resolution || "720p", generateAudio: settings.audio ? settings.audio !== "关闭" : p.generateAudio === true } }; + if (node.inputType === "image") { + if (["enhance", "extend", "remove-bg"].includes(definition.route || "")) return { kind: "image-operation", params: { operation: definition.route, prompt } }; + return { kind: "image", params: { prompt, model, allowFailover: false, imageCount: 1, imageSize: settings.resolution || p.imageSize || "1K", aspectRatio: settings.ratio || p.ratio || p.aspectRatio || "auto", referenceImages: p.referenceImages || [], billingFeature: getImageGenerationBillingFeature(key), promptExtend: true } }; + } + if (node.inputType === "text" || node.kind === "script" || node.kind === "text") return { kind: "analysis", params: { prompt, preferredModelRef: model, mode: key === "story-script" ? "story-script" : key === "video-analysis" ? "video-analysis" : "agent-plan", nodeTitle: node.title } }; + throw new Error(`${node.title}:尚未配置可执行动作`); + }, () => crypto.randomUUID()), [canvasId, videoCanvasFunctionConfig]); + + const applyTVstarShots = useCallback(async (shots: TVstarShot[], goal: string, references: TVstarReference[] = [], selection: TVstarGenerationSelection = {}, runMode: "submit" | "prepare" = "submit") => { + if (!shots.length) return false; + const videoDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "first-frame-video"); + if (runMode === "submit" && (!videoCanvasFunctionConfig.enabled || !videoDefinition.enabled)) { + toast.error("首帧生视频功能尚未启用,无法创建可执行分镜"); + return false; + } + let videoSettings: ReturnType[]; + let availableModels: CanvasModelOption[] = []; + try { + const preferredModel = selection.videoModelRef || getFunctionModel(videoCanvasFunctionConfig, videoDefinition); + availableModels = canvasModelOptions.length ? canvasModelOptions : + ((await fetch("/api/models", { cache: "no-store" }).then(response => response.json()))?.models || []) + .filter((model: CanvasModelOption) => getCanvasModelRef(model)); + if (selection.videoModelRef && !filterCanvasModels(availableModels, "video", "first-frame") + .some(model => model.id === selection.videoModelRef || model.modelId === selection.videoModelRef)) + throw new Error("所选视频模型不支持首帧生视频,请换一个模型"); + videoSettings = shots.map(shot => chooseStoryVideoSettings( + availableModels, true, Number.parseFloat(shot.duration) || 5, preferredModel, + )); + } catch (error) { + if (runMode === "submit") { + toast.error(error instanceof Error ? error.message : "没有可执行的首帧生视频模型"); + return false; + } + videoSettings = shots.map(shot => ({ + mode: "first-frame" as const, model: selection.videoModelRef || "", + duration: Math.max(1, Math.min(10, Math.ceil(Number.parseFloat(shot.duration) || 5))), + ratio: "16:9", resolution: "720p", generateAudio: false, + })); + } + let referenceImages: string[]; + try { + referenceImages = await resolveTVstarGenerationReferences(references); + if (runMode === "submit" && selection.imageModelRef && !filterCanvasModels(availableModels, referenceImages.length ? "image-edit" : "image") + .some(model => model.id === selection.imageModelRef || model.modelId === selection.imageModelRef)) + throw new Error(referenceImages.length ? "所选图片模型不支持参考图生成,请换一个模型" : "所选图片模型不可用,请换一个模型"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "参考视频处理失败"); + return false; + } + const referenceCapableImageModels = referenceImages.length ? filterCanvasModels(availableModels, "image-edit") : []; + if (runMode === "submit" && referenceImages.length && !referenceCapableImageModels.length) { + toast.error("没有支持参考图的图片模型,无法按引用素材生成关键帧"); + return false; + } + const imageModelRef = selection.imageModelRef || (referenceImages.length + ? (referenceCapableImageModels.find(model => model.id === videoCanvasFunctionConfig.defaultImageModel || model.modelId === videoCanvasFunctionConfig.defaultImageModel) + || referenceCapableImageModels[0])?.id : undefined); + const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); + const x = Math.round(center.x - 500); + const y = Math.round(center.y - 180); + const script = createTextResourceNode({ + content: [goal, ...shots.map((shot, index) => `镜头 ${shot.shot}(目标 ${shot.duration},生成 ${videoSettings[index].duration}s)${shot.sceneDescription}。运镜:${shot.cameraMovement}`)].join("\n\n"), + title: "TVstar · 创作分镜", + x, y, + }); + script.draftValues = { ...script.draftValues, tvstarShots: shots, tvstarReferences: references, source: "tvstar" }; + const timing = tvstarTiming(goal, shots); + const nextNodes: CloneNode[] = [script]; + const nextEdges: CloneEdge[] = []; + const videoNodeIds: string[] = []; + shots.forEach((shot, index) => { + const settings = videoSettings[index]; + const rowY = y + index * 450; + const imagePrompt = shot.imagePrompt || shot.sceneDescription; + const image = createTargetModuleNode("image", x + 560, rowY, nodes.length + index * 2 + 1); + image.title = `镜头 ${shot.shot} · 关键帧`; + image.draftValues = { + ...image.draftValues, + prompt: imagePrompt, + params: { prompt: imagePrompt, aspectRatio: "16:9", referenceImages, ...(imageModelRef ? { model: imageModelRef } : {}) }, + tvstarShot: shot.shot, + source: "tvstar", + }; + const video = createTargetModuleNode("video", x + 1020, rowY, nodes.length + index * 2 + 2); + video.title = `镜头 ${shot.shot} · 视频`; + video.draftValues = { + ...video.draftValues, + prompt: [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。"), + params: { + mode: "first-frame", + prompt: [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。"), + model: settings.model, + duration: settings.duration, + ratio: settings.ratio, + resolution: settings.resolution, + generateAudio: settings.generateAudio, + }, + tvstarShot: shot.shot, + source: "tvstar", + }; + nextNodes.push(image, video); + videoNodeIds.push(video.id); + nextEdges.push( + { id: makeId("edge"), source: script.id, target: image.id }, + { id: makeId("edge"), source: image.id, target: video.id }, + ); + }); + const finalVideo = createTargetModuleNode("smart-edit", x + 1510, y + 140, nodes.length + shots.length * 2 + 1); + finalVideo.title = `TVstar · ${timing.targetDuration} 秒成片`; + finalVideo.draftValues = { + ...finalVideo.draftValues, + source: "tvstar", + smartEditDraft: { + mode: "sequence", + ratio: "16:9", + resolution: "720p", + targetDuration: timing.targetDuration, + shotDurations: timing.shotDurations, + shotDescriptions: shots.map(shot => [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。").slice(0, 600)), + semantic: true, + preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel || undefined, + prompt: goal, + }, + }; + nextNodes.push(finalVideo); + nextEdges.push(...videoNodeIds.map(id => ({ id: makeId("edge"), source: id, target: finalVideo.id }))); + const groupId = makeId("tvstar-group"); + const groupNodes = makeCanvasGroup(nextNodes, nextNodes.map(node => node.id), groupId, runMode === "prepare" ? "TVstar · 待运行工作流" : "TVstar · 自动制作"); + let requests: ReturnType | undefined; + if (runMode === "submit") { + try { requests = compileCanvasGroupRun([...nodes, ...groupNodes], [...edges, ...nextEdges], groupId); } + catch (error) { toast.error(error instanceof Error ? error.message : "无法创建视频制作任务"); return false; } + } + addNodes(groupNodes, nextEdges); + setSelectedNodeId(groupId); + setViewport(current => ({ ...current, x: Math.round((window.innerWidth - (window.innerWidth > 800 ? 540 : 0)) / 2 - (x + 700) * current.zoom), y: Math.round(window.innerHeight / 2 - (y + 180) * current.zoom) })); + if (runMode === "prepare") { + toast.success(`已创建 ${shots.length} 个待运行镜头及成片节点,可在画布检查后开始制作`); + return groupId; + } + try { + await groupRuns.submit(requests!); + generationTasks.refresh(); + toast.success(`已开始制作 ${shots.length} 个镜头,完成后自动生成 ${timing.targetDuration} 秒成片`); + return groupId; + } catch (error) { + toast.error(`${error instanceof Error ? error.message : "提交失败"};节点已保留,可选中编组重试`); + return false; + } + }, [addNodes, canvasModelOptions, compileCanvasGroupRun, edges, generationTasks, groupRuns, nodes, toCanvasPoint, videoCanvasFunctionConfig]); + + const runPreparedTVstarGroup = useCallback(async (groupId: string) => { + let requests: ReturnType; + try { requests = compileCanvasGroupRun(nodes, edges, groupId); } + catch (error) { toast.error(error instanceof Error ? error.message : "待运行工作流已变化,无法开始制作"); return false; } + try { + await groupRuns.submit(requests); + generationTasks.refresh(); + toast.success("已开始执行画布中的 TVstar 工作流"); + return groupId; + } catch (error) { + toast.error(error instanceof Error ? error.message : "工作流提交失败"); + return false; + } + }, [compileCanvasGroupRun, edges, generationTasks, groupRuns, nodes]); + + const applyTVstarEdit = useCallback(async (references: TVstarReference[], goal: string) => { + const videos = references.filter(item => item.type === "video").slice(0, 12); + if (!videos.length) { toast.error("请先引用至少一个视频片段"); return false; } + const timing = tvstarTiming(goal, videos.map(item => ({ duration: `${Math.min(item.duration || 5, 5)}s` }))); + const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); + const x = Math.round(center.x - 380); + const y = Math.round(center.y - 180); + const brief = createTextResourceNode({ content: goal, title: "TVstar · 剪辑要求", x, y }); + brief.draftValues = { ...brief.draftValues, source: "tvstar", tvstarReferences: videos }; + const nextNodes: CloneNode[] = [brief]; + const nextEdges: CloneEdge[] = []; + const sources = videos.map((item, index) => { + const source = createMediaResourceNode({ + mediaType: "video", mediaUrl: item.url, title: `素材 ${index + 1} · ${item.title}`, + x: x + 480, y: y + index * 375, + }); + source.draftValues = { ...source.draftValues, source: "tvstar", originalReferenceId: item.id }; + nextNodes.push(source); + return source; + }); + const result = createTargetModuleNode("smart-edit", x + 1140, y + 90, nodes.length + videos.length + 1); + result.title = `TVstar · ${timing.targetDuration} 秒智能成片`; + result.draftValues = { ...result.draftValues, source: "tvstar", smartEditDraft: { + mode: "sequence", ratio: "16:9", resolution: "720p", + targetDuration: timing.targetDuration, shotDurations: timing.shotDurations, + shotDescriptions: videos.map(item => `从《${item.title}》选取符合用户要求的真实高光画面:${goal}`.slice(0, 600)), + semantic: true, preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel || undefined, + prompt: goal, + } }; + nextNodes.push(result); + nextEdges.push(...sources.map(source => ({ id: makeId("edge"), source: source.id, target: result.id }))); + const groupId = makeId("tvstar-edit-group"); + const groupNodes = makeCanvasGroup(nextNodes, nextNodes.map(node => node.id), groupId, "TVstar · 素材智能剪辑"); + let requests: ReturnType; + try { requests = compileCanvasGroupRun([...nodes, ...groupNodes], [...edges, ...nextEdges], groupId); } + catch (error) { toast.error(error instanceof Error ? error.message : "无法创建剪辑任务"); return false; } + addNodes(groupNodes, nextEdges); + setSelectedNodeId(groupId); + setViewport(current => ({ ...current, + x: Math.round((window.innerWidth - (window.innerWidth > 800 ? 540 : 0)) / 2 - (x + 700) * current.zoom), + y: Math.round(window.innerHeight / 2 - (y + 180) * current.zoom), + })); + try { + await groupRuns.submit(requests); + generationTasks.refresh(); + toast.success(`已开始分析 ${videos.length} 段真实视频并合成成片`); + return groupId; + } catch (error) { + toast.error(`${error instanceof Error ? error.message : "提交失败"};剪辑节点已保留,可选中编组重试`); + return false; + } + }, [addNodes, compileCanvasGroupRun, edges, generationTasks, groupRuns, nodes, toCanvasPoint, videoCanvasFunctionConfig.defaultMultimodalModel]); + + const applyCharacterStudio = useCallback(async (character: StudioCharacter, style: string, views: StudioView[], referenceUrl?: string): Promise => { + if (!views.length) return false; + const imageCandidates = filterCanvasModels(canvasModelOptions, referenceUrl ? "image-edit" : "image"); + if (referenceUrl && !imageCandidates.length) { + toast.error("没有支持参考图的图片模型,无法按角色参考图出图"); + return false; + } + const imageModelRef = imageCandidates.find(model => model.id === videoCanvasFunctionConfig.defaultImageModel + || model.modelId === videoCanvasFunctionConfig.defaultImageModel)?.id || imageCandidates[0]?.id; + const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); + const x = Math.round(center.x - 360); + const y = Math.round(center.y - 160); + const identity = [character.name, character.description, character.look, character.wardrobe, character.prompt, `风格:${style}`].filter(Boolean).join("。 "); + const source = createTextResourceNode({ content: identity, title: `${character.name} · 角色设定`, x, y }); + source.draftValues = { ...source.draftValues, source: "character-studio", characterIdentity: identity }; + const viewPrompts: Record = { + "角色立绘": "全身角色立绘,站姿自然,人物完整入画,纯净背景,清晰展示稳定面部特征、服装和道具。", + "脸部近景": "同一角色脸部近景,保持五官、发型与标志性特征完全一致,柔和电影布光。", + "表情参考": "同一角色表情设定图,四宫格分别展示平静、喜悦、惊讶、愤怒,五官与服装细节一致。", + "角色三视图": "同一角色正面、侧面、背面三视图并排,站姿和服装统一,清晰标注轮廓,纯净背景。", + }; + const nextNodes: CloneNode[] = [source]; + const nextEdges: CloneEdge[] = []; + views.forEach((view, index) => { + const prompt = `${identity}\n${viewPrompts[view]} 不改变角色身份,不增加多余人物。`; + const node = createTargetModuleNode("image", x + 500 + index * 440, y, nodes.length + index + 1); + node.title = `${character.name} · ${view}`; + node.draftValues = { + ...node.draftValues, + prompt, + params: { prompt, aspectRatio: view === "角色三视图" || view === "表情参考" ? "16:9" : "3:4", ...(referenceUrl ? { referenceImages: [referenceUrl] } : {}), ...(imageModelRef ? { model: imageModelRef } : {}) }, + characterIdentity: identity, + characterView: view, + source: "character-studio", + }; + nextNodes.push(node); + nextEdges.push({ id: makeId("edge"), source: source.id, target: node.id }); + }); + const groupId = makeId("character-group"); + const groupNodes = makeCanvasGroup(nextNodes, nextNodes.map(node => node.id), groupId, `${character.name} · 角色造型`); + let requests: ReturnType; + try { requests = compileCanvasGroupRun([...nodes, ...groupNodes], [...edges, ...nextEdges], groupId); } + catch (error) { toast.error(error instanceof Error ? error.message : "无法创建角色视图任务"); return false; } + addNodes(groupNodes, nextEdges); + setSelectedNodeId(groupId); + setViewport(current => ({ ...current, x: Math.round(window.innerWidth / 2 - (x + 570) * current.zoom), y: Math.round(window.innerHeight / 2 - (y + 180) * current.zoom) })); + try { + await groupRuns.submit(requests); + generationTasks.refresh(); + setActivePanel(null); + toast.success(`已开始生成 ${character.name} 的 ${views.length} 个角色视图`); + return true; + } catch (error) { + toast.error(`${error instanceof Error ? error.message : "角色视图提交失败"};节点已保留,可选中编组重试`); + return false; + } + }, [addNodes, canvasModelOptions, compileCanvasGroupRun, edges, generationTasks, groupRuns, nodes, toCanvasPoint, videoCanvasFunctionConfig.defaultImageModel]); + const readMediaIntrinsicSize = useCallback(async (file: File) => { if (file.type.startsWith("video/")) { return await new Promise<{ width: number; height: number } | null>((resolve) => { @@ -9719,29 +10070,7 @@ export function LiblibVideoCanvasReplica({ if(!selectedGroup||groupSubmitting)return; setGroupSubmitting(true); try { - const requests=compileGroupRun(nodes,edges,selectedGroup.id,canvasId,n=>{ - const node=n as CloneNode,d=node.draftValues||{},p=getNodeParams(node),settings=(p.settings||{}) as Record; - if(d.action==="VIDEO_HD_UPSCALE")return {kind:"video-upscale",params:{resolution:d.hdResolution||"1080P"}}; - if(d.audioParams)return {kind:"audio",params:d.audioParams as Record}; - if(getTargetModuleKind(node)==="smart-edit"||d.nodeType==="smart-edit"){ - const draft=(d.smartEditDraft||{}) as any,plan=draft.plans?.[draft.selectedPlanIndex||0]||d.editPlan; - const changes=edges.some(e=>e.target===node.id&&groupMembers(nodes,selectedGroup.id).some(m=>m.id===e.source&&(/GENERATE|UPSCALE/.test(String(m.draftValues?.action))||m.draftValues?.generationTaskId))); - if(plan&&!changes)return {kind:"smart-edit",params:{operation:"render",plan}}; - return {kind:"smart-edit",params:{operation:"plan",autoRender:true,mode:draft.mode||"sequence",targetDuration:draft.targetDuration||300,ratio:draft.ratio||"16:9",resolution:draft.resolution||"720p",semantic:draft.semantic??false,prompt:draft.prompt||"",removeSilence:draft.removeSilence||false,transcribe:draft.transcribe||false,music:draft.music,beatSync:draft.beatSync||false,variants:1}}; - } - const key=String(p.functionKey||d.functionKey||(node.inputType==="video"?"text-to-video":node.inputType==="image"?"":"story-script")); - const definition=key?getConfiguredFunction(videoCanvasFunctionConfig,key):{key:"image-generate",kind:"image-edit" as const,route:"canvas-generate-image" as const,enabled:true,label:"图片生成",prompt:"",description:""}; - if(!definition?.enabled||!videoCanvasFunctionConfig.enabled)throw new Error(`${node.title}:功能未启用或未配置`); - const model=getFunctionModel(videoCanvasFunctionConfig,definition,normalizeModelRef(settings.model||p.model)); - const prompt=composeFunctionPrompt(definition,String(p.prompt||d.prompt||"")); - if(node.inputType==="video")return {kind:"video",params:{prompt,preferredModelRef:model,mode:p.mode||settings.mode||(edges.some(e=>e.target===node.id&&nodes.find(n=>n.id===e.source)?.inputType==="image")?"first-frame":"text-to-video"),duration:Number(String(settings.duration||p.duration||5).replace(/s$/,"")),ratio:settings.ratio||p.ratio||"16:9",resolution:settings.resolution||p.resolution||"720p",generateAudio:settings.audio!=="关闭"}}; - if(node.inputType==="image"){ - if(["enhance","extend","remove-bg"].includes(definition.route||""))return {kind:"image-operation",params:{operation:definition.route,prompt}}; - return {kind:"image",params:{prompt,model,allowFailover:false,imageCount:1,imageSize:settings.resolution||p.imageSize||"1K",aspectRatio:settings.ratio||p.ratio||"auto",billingFeature:getImageGenerationBillingFeature(key),promptExtend:true}}; - } - if(node.inputType==="text"||node.kind==="script"||node.kind==="text")return {kind:"analysis",params:{prompt,preferredModelRef:model,mode:key==="story-script"?"story-script":key==="video-analysis"?"video-analysis":"agent-plan",nodeTitle:node.title}}; - throw new Error(`${node.title}:尚未配置可执行动作`); - },()=>crypto.randomUUID()); + const requests=compileCanvasGroupRun(nodes,edges,selectedGroup.id); await groupRuns.submit(requests); generationTasks.refresh();toast.success(`已提交 ${requests.length} 个节点,按连线依赖执行`); }catch(e){toast.error((e as Error).message);}finally{setGroupSubmitting(false);} @@ -10535,7 +10864,7 @@ export function LiblibVideoCanvasReplica({ return (
+ {activePanel === "tvstar" ? ( + item.id === selectedNodeId) || null} + onApply={applyTVstarShots} + onPrepare={(shots, goal, references, selection) => applyTVstarShots(shots, goal, references, selection, "prepare")} + onRunPrepared={runPreparedTVstarGroup} + onEdit={applyTVstarEdit} + onClose={() => setActivePanel(null)} + onOpenPlugin={() => window.open("/tvstar/plugin", "_blank", "noopener,noreferrer")} + docked={tvstarDocked} + dockWidth={tvstarDockWidth} + onDockChange={(value) => { setTvstarDocked(value); try { localStorage.setItem(`tvstar:docked:${canvasId}`, value ? "1" : "0"); } catch { /* optional */ } }} + onDockWidthChange={(value) => { setTvstarDockWidth(value); try { localStorage.setItem(`tvstar:dock-width:${canvasId}`, String(value)); } catch { /* optional */ } }} + onUpload={async file => { + const uploaded = await uploadCanvasMediaFile(file); + if (uploaded.mediaType !== "image" && uploaded.mediaType !== "video") throw new Error("请选择图片或视频素材"); + return { url: uploaded.url, type: uploaded.mediaType, duration: uploaded.duration }; + }} + /> + ) : null} + + {activePanel === "characterStudio" ? ( + setActivePanel(null)} + onOpenLibrary={() => setActivePanel("roleLibrary")} + onUpload={async file => { + const uploaded = await uploadCanvasMediaFile(file); + if (uploaded.mediaType !== "image") throw new Error("请选择图片参考素材"); + return uploaded.url; + }} + /> + ) : null} + ); })} +
+ + TVstar +
); } @@ -23554,14 +23941,12 @@ function SkillsAccessModal({

我的Access key

· Access key 仅用于 {skillsName} 的调用授权使用,一旦传播给他人,有账号泄露的风险,请谨慎保管!

-

· {skillsName} 是 {siteName} 提供给 agent 使用的入口,可在 openclaw 中调用,完成专业视频创作。

+

· {skillsName} 可授权 Agent 读取和操作画布节点;视频生成仍需通过画布任务执行。

· 安装使用请戳

+
+
+ +