"use client"; import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { createPortal } from "react-dom"; import { ArrowUp, Check, ChevronDown, Clock3, Copy, Download, FilePlus2, Loader2, Minus, PanelRight, Paperclip, Plug, Plus, Share2, SlidersHorizontal, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { requestCreativeAnalysis, type CreativeAnalysisModel } from "./creativeAnalysisClient"; import { buildTVstarRevisionPrompt, tvstarAllowsGeneration, tvstarRequestsRunExisting, tvstarWantsDraftNodes, type TVstarMessage } from "./session-context"; import type { CanvasGenerationTask } from "@/lib/video-canvas/task-contract"; import { mergeTVstarSessions } from "@/lib/video-canvas/tvstar-session"; export type TVstarShot = { shot: string; duration: string; scene: string; sceneDescription: string; shotSize: string; lighting: string; dialogue: string; backgroundMusic: string; voiceAndSfx: string; cameraMovement: string; imagePrompt: string; motionPrompt: string; keyframeUrl: string; }; export type TVstarReference = { id: string; title: string; url: string; type: "image" | "video"; duration?: number }; export type TVstarNodeContext = { id: string; title: string; type: string; description: string }; export type TVstarModel = CreativeAnalysisModel; export type TVstarGenerationModel = { id: string; label: string; kind: "image" | "video"; supportsReference?: boolean }; export type TVstarGenerationSelection = { imageModelRef?: string; videoModelRef?: string }; type Plan = { summary: string; agentPlan: string[]; deliverables: string[]; recommendedNodes: string[]; model?: string; }; type Session = { id: string; title: string; goal: string; mode: Mode; plan?: Plan; shots?: TVstarShot[]; references?: TVstarReference[]; nodeContexts?: TVstarNodeContext[]; messages?: TVstarMessage[]; imageModelRef?: string; videoModelRef?: string; autoGenerate?: boolean; createdAt: number; updatedAt?: number; shareToken?: string; pendingStage?: "plan" | "shots" | "submit"; runSubmitted?: boolean; preparedOnly?: boolean; groupId?: string; }; type Mode = "full" | "brainstorm" | "script" | "storyboard" | "edit"; const modes: { value: Mode; label: string; description: string }[] = [ { value: "full", label: "全能创作", description: "自动规划、生成镜头、选片并合成视频" }, { value: "brainstorm", label: "创意脑暴", description: "拆解方向与交付物" }, { value: "script", label: "剧本创编", description: "直接生成结构化镜头脚本" }, { value: "storyboard", label: "批量分镜", description: "将故事拆成画布镜头" }, { value: "edit", label: "片段智能剪辑", description: "从引用的视频中选高光并直接合成" }, ]; const templates = [ { name: "产品广告", category: "商业广告", description: "使用场景、卖点和品牌收尾", prompt: "创作一支 15 秒的产品广告。先明确产品、受众与核心卖点,用使用场景建立需求,安排产品特写和清晰的品牌收尾。" }, { name: "新中式美学 TVC", category: "商业广告", description: "中式材质、留白和电影光影", prompt: "创作一支新中式美学 TVC。统一器物、空间、服饰与色彩;用留白、自然材质和克制的镜头运动呈现产品,不要无关人物。" }, { name: "电商展示片", category: "商业广告", description: "卖点可见、动作清楚、适合竖屏", prompt: "创作一支电商商品展示视频。每个镜头只传达一个可见卖点,展示使用动作、细节特写和明确收尾;避免生成无法验证的功效。" }, { name: "电影感短片", category: "剧情短片", description: "人物、冲突、情绪转折", prompt: "创作一支 30 秒的电影感短片。先明确人物、冲突与情绪转折,再安排景别变化、动作衔接和声音。" }, { name: "角色出场", category: "剧情短片", description: "外观锚点与层层揭示", prompt: "设计角色首次出场的短片。保持面部、发型、服装和道具一致,通过景别和运镜逐步揭示人物。" }, { name: "旅行 Vlog", category: "剧情短片", description: "地点、人物和环境声连贯", prompt: "创作电影感旅行 Vlog。建立地点与人物,按出发、探索、发现、收尾推进;保持时空顺序和环境声连续。" }, { name: "参考片风格复刻", category: "素材改编", description: "分析参考视频后生成新内容", prompt: "分析我引用的参考视频的镜头节奏、构图、光影和情绪,生成主题不同但视听语言一致的新作品。不要复制原片人物、标识或台词。" }, { name: "故事改编", category: "素材改编", description: "保留人物关系和关键情节", prompt: "将我上传或粘贴的故事改编为可生成的视频分镜,保留角色关系和关键情节,压缩次要情节并保持前后连贯。" }, { name: "分镜预演", category: "分镜脚本", description: "先确定镜头与动作,再制作", prompt: "先拆出清楚的镜头目标、景别、时长、人物动作和前后衔接。每个镜头只描述一个可执行动作,优先保证角色和场景一致。" }, ]; const panel = "rounded-xl border border-white/10 bg-[#252525]"; function isShot(value: unknown): value is TVstarShot { return Boolean(value && typeof value === "object" && typeof (value as TVstarShot).sceneDescription === "string"); } function productionShots(value: unknown) { const shots = Array.isArray(value) ? value.filter(isShot) : []; if (shots.length > 12) throw Error("自动视频制作最多支持12个镜头,请拆分制作;不会截掉多余镜头"); return shots; } export function TVstarPanel({ canvasId, models, generationModels, tasks, references, canvasNodes, selectedCanvasNode, onApply, onPrepare, onRunPrepared, onEdit, onClose, onOpenPlugin, docked, dockWidth, onDockChange, onDockWidthChange, onUpload, }: { canvasId: string; models: TVstarModel[]; generationModels: TVstarGenerationModel[]; tasks: CanvasGenerationTask[]; references: TVstarReference[]; canvasNodes: TVstarNodeContext[]; selectedCanvasNode: TVstarNodeContext | null; onApply: (shots: TVstarShot[], goal: string, references: TVstarReference[], selection: TVstarGenerationSelection) => Promise; onPrepare: (shots: TVstarShot[], goal: string, references: TVstarReference[], selection: TVstarGenerationSelection) => Promise; onRunPrepared: (groupId: string) => Promise; onEdit: (references: TVstarReference[], goal: string) => Promise; onClose: () => void; onOpenPlugin: () => void; docked: boolean; dockWidth: number; onDockChange: (value: boolean) => void; onDockWidthChange: (value: number) => void; onUpload: (file: File) => Promise<{ url: string; type: "image" | "video"; duration?: number }>; }) { const key = `tvstar:sessions:${canvasId}`; const [sessions, setSessions] = useState([]); const sessionsRef = useRef([]); const [syncReady, setSyncReady] = useState(false); const [syncError, setSyncError] = useState(false); const [shareConfirm, setShareConfirm] = useState(false); const [shareBusy, setShareBusy] = useState(false); const [activeId, setActiveId] = useState(null); const [goal, setGoal] = useState(""); const [mode, setMode] = useState("full"); const [modeOpen, setModeOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false); const [modelRef, setModelRef] = useState(""); const [modelTab, setModelTab] = useState<"image" | "video" | "planning">("video"); const [imageModelRef, setImageModelRef] = useState(""); const [videoModelRef, setVideoModelRef] = useState(""); const [autoGenerate, setAutoGenerate] = useState(true); const [policyOpen, setPolicyOpen] = useState(false); const [templatesOpen, setTemplatesOpen] = useState(false); const [skillSearch, setSkillSearch] = useState(""); const [historyOpen, setHistoryOpen] = useState(false); const [headerMenu, setHeaderMenu] = useState<"share" | "settings" | null>(null); const [notificationsEnabled, setNotificationsEnabled] = useState(false); const [notificationSound, setNotificationSound] = useState(false); const audioContextRef = useRef(null); const notifiedTaskRef = useRef(null); const [referencesOpen, setReferencesOpen] = useState(false); const [selectedRefs, setSelectedRefs] = useState([]); const [selectedContextNodes, setSelectedContextNodes] = useState([]); const lastSelectedCanvasNodeId = useRef(null); const [restoredDraftFor, setRestoredDraftFor] = useState(""); const [busy, setBusy] = useState<"plan" | "shots" | "submit" | "upload" | null>(null); const [error, setError] = useState(""); const fileRef = useRef(null); const onApplyRef = useRef(onApply); const onPrepareRef = useRef(onPrepare); const onRunPreparedRef = useRef(onRunPrepared); const onEditRef = useRef(onEdit); const modelsRef = useRef(models); useEffect(() => { onApplyRef.current = onApply; }, [onApply]); useEffect(() => { onPrepareRef.current = onPrepare; }, [onPrepare]); useEffect(() => { onRunPreparedRef.current = onRunPrepared; }, [onRunPrepared]); useEffect(() => { onEditRef.current = onEdit; }, [onEdit]); useEffect(() => { modelsRef.current = models; }, [models]); const active = sessions.find(item => item.id === activeId); const activeTasks = active?.groupId ? tasks.filter(task => task.request.workflow?.groupId === active.groupId) : []; const finalTask = activeTasks.find(task => task.request.kind === "smart-edit" && task.request.params.operation === "render"); const failedTask = activeTasks.find(task => (task.status === "failed" || task.status === "interrupted") && task.error && !task.error.startsWith("上游任务失败")) || finalTask; const finalUrl = typeof finalTask?.result?.url === "string" ? finalTask.result.url : ""; const productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作" : activeTasks.some(task => task.status === "failed" || task.status === "interrupted") ? "有任务需要处理,请查看画布中的失败节点" : finalUrl ? "成片已完成" : activeTasks.some(task => task.status === "running") ? "正在生成或分析视频" : activeTasks.length ? "任务已排队" : "任务已提交,正在同步进度"; const mention = goal.match(/(?:^|\s)@([^@\s]*)$/)?.[1]; const mentionMatches = mention === undefined ? [] : references.filter(item => item.title.toLowerCase().includes(mention.toLowerCase()) && !selectedRefs.some(selected => selected.url === item.url), ).slice(0, 6); const mentionModelMatches = mention === undefined ? [] : generationModels.filter(item => item.label.toLowerCase().includes(mention.toLowerCase()) && (item.kind !== "image" || !(selectedRefs.length || active?.references?.length) || item.supportsReference !== false), ).slice(0, 8); const mentionNodeMatches = mention === undefined ? [] : canvasNodes.filter(item => item.title.toLowerCase().includes(mention.toLowerCase()) && !selectedContextNodes.some(selected => selected.id === item.id), ).slice(0, 6); const message = (role: TVstarMessage["role"], content: string): TVstarMessage => ({ id: crypto.randomUUID(), role, content, createdAt: Date.now(), }); const updateSession = (updated: Session) => saveSessions(sessionsRef.current.map(item => item.id === updated.id ? updated : item)); useEffect(() => { try { const saved = JSON.parse(localStorage.getItem(key) || "[]"); if (Array.isArray(saved)) { const restoredSessions = saved.filter((item): item is Session => Boolean(item && typeof item.id === "string" && typeof item.createdAt === "number" && Array.isArray(item.messages))).slice(0, 20); sessionsRef.current = restoredSessions; setSessions(restoredSessions); const remembered = localStorage.getItem(`tvstar:active:${canvasId}`); const restored = saved.find((item: Session) => item.id === remembered) || saved[0]; setActiveId(restored?.id || null); setImageModelRef(restored?.imageModelRef || ""); setVideoModelRef(restored?.videoModelRef || ""); } setAutoGenerate(localStorage.getItem(`tvstar:auto:${canvasId}`) !== "manual"); setNotificationsEnabled(localStorage.getItem(`tvstar:notifications:${canvasId}`) === "1"); setNotificationSound(localStorage.getItem(`tvstar:notification-sound:${canvasId}`) === "1"); setGoal(localStorage.getItem(`tvstar:draft:${canvasId}`) || ""); const draftRefs = JSON.parse(localStorage.getItem(`tvstar:draft-refs:${canvasId}`) || "[]"); if (Array.isArray(draftRefs)) setSelectedRefs(draftRefs.filter((item): item is TVstarReference => Boolean(item && typeof item.url === "string" && (item.type === "image" || item.type === "video"))).slice(0, 6)); const draftNodes = JSON.parse(localStorage.getItem(`tvstar:draft-nodes:${canvasId}`) || "[]"); if (Array.isArray(draftNodes)) setSelectedContextNodes(draftNodes.filter((item): item is TVstarNodeContext => Boolean(item && typeof item.id === "string" && typeof item.title === "string")).slice(0, 6)); } catch { /* a damaged local draft must not block the canvas */ } setRestoredDraftFor(canvasId); }, [key]); useEffect(() => { if (restoredDraftFor !== canvasId) return; let cancelled = false; void fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions`, { cache: "no-store" }) .then(async response => { if (!response.ok) throw new Error("读取对话失败"); return response.json(); }).then(data => { if (cancelled) return; const rawRemote: unknown[] = Array.isArray(data.sessions) ? data.sessions : []; const remote: Session[] = rawRemote.filter((item): item is Session => Boolean(item && typeof item === "object" && typeof (item as Session).id === "string" && typeof (item as Session).createdAt === "number")); const sharedById = new Map(remote.map(item => [item.id, item.shareToken])); const merged = mergeTVstarSessions(sessionsRef.current, remote).map(item => sharedById.has(item.id) ? { ...item, shareToken: sharedById.get(item.id) } : item); sessionsRef.current = merged; setSessions(merged); try { localStorage.setItem(key, JSON.stringify(merged)); } catch { /* browser storage may be unavailable */ } setActiveId(current => current || merged[0]?.id || null); setSyncError(false); }).catch(() => { if (!cancelled) setSyncError(true); }) .finally(() => { if (!cancelled) setSyncReady(true); }); return () => { cancelled = true; }; }, [canvasId, key, restoredDraftFor]); useEffect(() => { if (!syncReady || restoredDraftFor !== canvasId || !sessions.length) return; const controller = new AbortController(); const timer = window.setTimeout(() => { void fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessions: sessions.map(item => ({ ...item, updatedAt: item.updatedAt || item.createdAt })) }), signal: controller.signal, }).then(response => { if (!response.ok) throw new Error("保存对话失败"); setSyncError(false); }) .catch(error => { if (error?.name !== "AbortError") setSyncError(true); }); }, 450); return () => { window.clearTimeout(timer); controller.abort(); }; }, [canvasId, restoredDraftFor, sessions, syncReady]); useEffect(() => { if (restoredDraftFor !== canvasId) return; try { localStorage.setItem(`tvstar:draft:${canvasId}`, goal); localStorage.setItem(`tvstar:draft-refs:${canvasId}`, JSON.stringify(selectedRefs)); localStorage.setItem(`tvstar:draft-nodes:${canvasId}`, JSON.stringify(selectedContextNodes)); } catch { /* storage is optional */ } }, [canvasId, goal, restoredDraftFor, selectedRefs, selectedContextNodes]); useEffect(() => { if (!finalTask || finalTask.status !== "succeeded" || !finalUrl || !notificationsEnabled) return; if (notifiedTaskRef.current === finalTask.id || typeof Notification === "undefined" || Notification.permission !== "granted") return; notifiedTaskRef.current = finalTask.id; new Notification("TVstar 成片已完成", { body: active?.title || "视频已完成,可以在画布中预览。" }); if (notificationSound) { void (async () => { try { const context = audioContextRef.current || new AudioContext(); audioContextRef.current = context; await context.resume(); const oscillator = context.createOscillator(); const gain = context.createGain(); oscillator.type = "sine"; oscillator.frequency.setValueAtTime(720, context.currentTime); oscillator.frequency.setValueAtTime(960, context.currentTime + 0.12); gain.gain.setValueAtTime(0.0001, context.currentTime); gain.gain.exponentialRampToValueAtTime(0.08, context.currentTime + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.32); oscillator.connect(gain).connect(context.destination); oscillator.start(); oscillator.stop(context.currentTime + 0.34); } catch { /* Browser playback may require another user gesture. */ } })(); } }, [active?.title, finalTask, finalUrl, notificationSound, notificationsEnabled]); useEffect(() => () => { void audioContextRef.current?.close(); }, []); useEffect(() => { if (!selectedCanvasNode) { lastSelectedCanvasNodeId.current = null; return; } if (lastSelectedCanvasNodeId.current === selectedCanvasNode.id) return; lastSelectedCanvasNodeId.current = selectedCanvasNode.id; setSelectedContextNodes(current => current.some(item => item.id === selectedCanvasNode.id) ? current : [...current, selectedCanvasNode].slice(0, 6)); const media = references.find(item => item.id === selectedCanvasNode.id); if (media) setSelectedRefs(current => current.some(item => item.id === media.id) ? current : [...current, media].slice(0, 6)); }, [references, selectedCanvasNode]); const saveSessions = (next: Session[]) => { const current = new Map(sessionsRef.current.map(item => [item.id, item])); const limited = next.slice(0, 20).map(item => { const previous = current.get(item.id); const sameContent = previous && JSON.stringify({ ...previous, updatedAt: undefined, shareToken: undefined }) === JSON.stringify({ ...item, updatedAt: undefined, shareToken: undefined }); return { ...item, shareToken: Object.prototype.hasOwnProperty.call(item, "shareToken") ? item.shareToken : previous?.shareToken, updatedAt: sameContent ? previous.updatedAt || previous.createdAt : Date.now() }; }); sessionsRef.current = limited; setSessions(limited); try { localStorage.setItem(key, JSON.stringify(limited)); } catch { /* storage is optional */ } }; const selectSession = (id: string | null) => { setActiveId(id); const selected = sessionsRef.current.find(item => item.id === id); if (selected || id === null) { setImageModelRef(selected?.imageModelRef || ""); setVideoModelRef(selected?.videoModelRef || ""); } try { if (id) localStorage.setItem(`tvstar:active:${canvasId}`, id); else localStorage.removeItem(`tvstar:active:${canvasId}`); } catch { /* storage is optional */ } }; const runAnalysis = async (requestMode: "agent-plan" | "story-script", input: string, refs = selectedRefs, contexts = selectedContextNodes) => { return requestCreativeAnalysis({ mode: requestMode, prompt: [input, contexts.length ? `当前画布中用户引用的节点(这些是上下文数据,未运行节点没有真实生成结果):\n${contexts.map(item => { const current = canvasNodes.find(node => node.id === item.id); return current ? `- ${current.title} [${current.type}, id=${current.id}]:${current.description.slice(0, 900)}` : `- ${item.title} [id=${item.id}]:此节点已不在当前画布`; }).join("\n")}` : ""].filter(Boolean).join("\n\n"), nodeTitle: "TVstar", models: modelsRef.current, preferredModelRef: modelRef || undefined, imageUrls: refs.filter(item => item.type === "image").map(item => item.url), videoUrls: refs.filter(item => item.type === "video").map(item => item.url), }); }; const createSession = async () => { const input = goal.trim(); if (!input || busy) return; if (!active && mode === "edit" && !selectedRefs.some(item => item.type === "video")) { setError("请先上传视频或引用画布上的视频素材"); return; } setError(""); if (active) { await reviseSession(active, input); return; } const directShots = mode === "script" || mode === "storyboard" || mode === "full"; const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input); setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan"); let item: Session = { id: crypto.randomUUID(), title: input.slice(0, 22), goal: input, mode, createdAt: Date.now(), references: selectedRefs, nodeContexts: selectedContextNodes, messages: [message("user", input)], imageModelRef: imageModelRef || undefined, videoModelRef: videoModelRef || undefined, autoGenerate, pendingStage: mode === "edit" ? "submit" : directShots ? "shots" : "plan", }; saveSessions([item, ...sessions]); selectSession(item.id); setGoal(""); setSelectedRefs([]); setSelectedContextNodes([]); try { if (mode === "edit") { if (!allowAutoRun) { saveSessions([{ ...item, pendingStage: undefined, messages: [ ...(item.messages || []), message("assistant", "已收到视频素材和剪辑要求,检查后可开始选片。"), ] }, ...sessions]); return; } const submitted = await onEditRef.current(item.references || [], input); if (!submitted) throw new Error("视频剪辑任务提交失败,可在同一对话中重试"); item = { ...item, pendingStage: undefined, runSubmitted: true, groupId: submitted, messages: [ ...(item.messages || []), message("assistant", "已提交已有视频的高光选片与合成任务。"), ] }; saveSessions([item, ...sessions]); return; } const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []); const shots = directShots ? productionShots(data.analysisRows) : undefined; if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试"); item = { ...item, plan: directShots && mode !== "full" ? undefined : { summary: String(data.summary || ""), agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String) : Array.isArray(data.recommendedNextSteps) ? data.recommendedNextSteps.map(String) : [], deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [], recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [], model: typeof data.model === "string" ? data.model : undefined, }, shots, pendingStage: mode === "full" ? "shots" : undefined, }; item.messages = [...(item.messages || []), message("assistant", directShots ? `已生成 ${shots?.length || 0} 个可执行分镜。` : item.plan?.summary || "已完成创作规划。")]; saveSessions([item, ...sessions]); if (directShots && shots?.length && (mode === "storyboard" || (tvstarWantsDraftNodes(input) && !allowAutoRun))) { const prepared = await onPrepareRef.current(shots, input, item.references || [], item); if (!prepared) throw new Error("待运行工作流创建失败,分镜已保留,可重试"); saveSessions([{ ...item, groupId: prepared, preparedOnly: true, messages: [ ...(item.messages || []), message("assistant", "已在画布中创建未执行的分镜工作流,没有提交生成任务。"), ] }, ...sessions]); return; } if (mode === "full") { setBusy("shots"); const storyboard = item.shots?.length ? { analysisRows: item.shots } : await runAnalysis("story-script", [input, item.plan?.summary, item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []); const generated = productionShots(storyboard.analysisRows); if (!generated.length) throw new Error("模型没有返回有效分镜,请重试"); item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [ ...(item.messages || []), message("assistant", allowAutoRun ? `已生成 ${generated.length} 个分镜,正在提交画布制作。` : `已生成 ${generated.length} 个分镜,检查后可点击“生成视频并合成成片”。`), ] }; saveSessions([item, ...sessions]); if (!allowAutoRun) { if (tvstarWantsDraftNodes(input)) { const prepared = await onPrepareRef.current(generated, input, item.references || [], item); if (!prepared) throw new Error("待运行工作流创建失败,分镜已保留,可重试"); saveSessions([{ ...item, groupId: prepared, preparedOnly: true, messages: [ ...(item.messages || []), message("assistant", "已在画布中创建未执行的脚本、关键帧、视频和成片节点,并连好依赖;没有提交生成任务。"), ] }, ...sessions]); } return; } setBusy("submit"); const submitted = await onApplyRef.current(generated, input, item.references || [], item); if (!submitted) throw new Error("视频任务提交失败,分镜已保留,可再次制作"); saveSessions([{ ...item, pendingStage: undefined, runSubmitted: true, groupId: submitted, messages: [ ...(item.messages || []), message("assistant", "已将分镜、生图、生视频和成片剪辑任务提交到画布。"), ] }, ...sessions]); } } catch (cause) { setError(cause instanceof Error ? cause.message : "TVstar 分析失败"); } finally { setBusy(null); } }; const reviseSession = async (session: Session, instruction: string) => { setError(""); setBusy(session.mode === "brainstorm" ? "plan" : session.mode === "edit" ? "submit" : "shots"); const mergedRefs = [...(session.references || []), ...selectedRefs].filter((item, index, list) => list.findIndex(candidate => candidate.url === item.url) === index).slice(0, 6); const mergedContexts = [...(session.nodeContexts || []), ...selectedContextNodes].filter((item, index, list) => list.findIndex(candidate => candidate.id === item.id) === index).slice(0, 6); const allowAutoRun = autoGenerate && tvstarAllowsGeneration(instruction); const pending: Session = { ...session, references: mergedRefs, nodeContexts: mergedContexts, imageModelRef: imageModelRef || undefined, videoModelRef: videoModelRef || undefined, autoGenerate, messages: [ ...(session.messages || []), message("user", instruction), ] }; updateSession(pending); setGoal(""); setSelectedRefs([]); setSelectedContextNodes([]); try { if (session.preparedOnly && session.groupId && tvstarRequestsRunExisting(instruction)) { setBusy("submit"); const submitted = await onRunPreparedRef.current(session.groupId); if (!submitted) throw new Error("现有画布工作流提交失败,请检查节点后重试"); updateSession({ ...pending, preparedOnly: false, runSubmitted: true, groupId: submitted, messages: [ ...(pending.messages || []), message("assistant", "已启动画布中的待运行工作流。"), ] }); return; } if (session.mode === "edit") { if (!allowAutoRun) { updateSession({ ...pending, pendingStage: undefined, runSubmitted: false, messages: [ ...(pending.messages || []), message("assistant", "已更新剪辑要求,点击下方按钮开始选片。"), ] }); return; } const submitted = await onEditRef.current(mergedRefs, `${session.goal}\n最新修改:${instruction}`); if (!submitted) throw new Error("视频剪辑任务提交失败,可重试"); updateSession({ ...pending, pendingStage: undefined, runSubmitted: true, groupId: submitted, messages: [ ...(pending.messages || []), message("assistant", "已按新要求重新分析片段并提交成片。"), ] }); return; } const prompt = buildTVstarRevisionPrompt(pending, instruction); if (session.mode === "brainstorm") { const data = await runAnalysis("agent-plan", prompt, mergedRefs, mergedContexts); const plan: Plan = { summary: String(data.summary || ""), agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String) : [], deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [], recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [], }; updateSession({ ...pending, plan, messages: [...(pending.messages || []), message("assistant", plan.summary || "已更新规划。")] }); return; } const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts); const shots = productionShots(data.analysisRows); if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求"); let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined, messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] }; updateSession(updated); if (!allowAutoRun && tvstarWantsDraftNodes(instruction)) { const prepared = await onPrepareRef.current(shots, `${session.goal}\n最新修改:${instruction}`, mergedRefs, pending); if (!prepared) throw new Error("修订工作流创建失败,分镜已保留,可重试"); updated = { ...updated, groupId: prepared, preparedOnly: true, messages: [ ...(updated.messages || []), message("assistant", "已在画布中创建一版待运行的修订工作流,没有提交生成任务。"), ] }; updateSession(updated); return; } if (session.mode === "full" && allowAutoRun) { setBusy("submit"); const submitted = await onApplyRef.current(shots, `${session.goal}\n最新修改:${instruction}`, mergedRefs, pending); if (!submitted) throw new Error("新版视频任务提交失败,更新后的分镜已保留,可重试"); updated = { ...updated, pendingStage: undefined, runSubmitted: true, preparedOnly: false, groupId: submitted, messages: [ ...(updated.messages || []), message("assistant", "已按新要求将视频和成片任务提交到画布。"), ] }; updateSession(updated); } } catch (cause) { setError(cause instanceof Error ? cause.message : "修改创作方案失败"); } finally { setBusy(null); } }; const generateShots = async () => { if (!active || busy) return; setError(""); setBusy("shots"); try { const data = await runAnalysis("story-script", [active.goal, active.plan?.summary, active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []); const shots = productionShots(data.analysisRows); if (!shots.length) throw new Error("模型没有返回有效分镜,请重试"); const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal; const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest); const updated = { ...active, shots, pendingStage: active.mode === "full" && allowAutoRun ? "submit" as const : undefined }; saveSessions(sessions.map(item => item.id === active.id ? updated : item)); if (!allowAutoRun && tvstarWantsDraftNodes(latest)) { const prepared = await onPrepareRef.current(shots, active.goal, active.references || [], active); if (!prepared) throw new Error("待运行工作流创建失败,分镜已保留,可重试"); saveSessions(sessions.map(item => item.id === active.id ? { ...updated, groupId: prepared, preparedOnly: true } : item)); return; } if (active.mode === "full" && allowAutoRun) { setBusy("submit"); const submitted = await onApplyRef.current(shots, active.goal, active.references || [], active); if (!submitted) throw new Error("视频任务提交失败,分镜已保留,可再次制作"); saveSessions(sessions.map(item => item.id === active.id ? { ...updated, pendingStage: undefined, runSubmitted: true, preparedOnly: false, groupId: submitted } : item)); } } catch (cause) { setError(cause instanceof Error ? cause.message : "分镜生成失败"); } finally { setBusy(null); } }; const newSession = () => { selectSession(null); setGoal(""); setSelectedRefs([]); setSelectedContextNodes([]); setError(""); setHistoryOpen(false); }; const submitExisting = async () => { if (!active?.shots?.length || busy) return; setError(""); setBusy("submit"); try { const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal; const submitted = active.preparedOnly && active.groupId ? await onRunPreparedRef.current(active.groupId) : await onApplyRef.current(active.shots, latest === active.goal ? active.goal : `${active.goal}\n最新修改:${latest}`, active.references || [], active); if (!submitted) throw new Error("视频任务提交失败,分镜已保留,可重试"); saveSessions(sessions.map(item => item.id === active.id ? { ...item, pendingStage: undefined, runSubmitted: true, preparedOnly: false, groupId: submitted } : item)); } catch (cause) { setError(cause instanceof Error ? cause.message : "视频制作失败"); } finally { setBusy(null); } }; const submitEditExisting = async () => { if (!active?.references?.some(item => item.type === "video") || busy) return; setError(""); setBusy("submit"); try { const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal; const submitted = await onEditRef.current(active.references, latest === active.goal ? active.goal : `${active.goal}\n最新修改:${latest}`); if (!submitted) throw new Error("视频剪辑任务提交失败,可重试"); updateSession({ ...active, runSubmitted: true, groupId: submitted, messages: [ ...(active.messages || []), message("assistant", "已开始分析真实视频并合成成片。"), ] }); } catch (cause) { setError(cause instanceof Error ? cause.message : "视频剪辑失败"); } finally { setBusy(null); } }; const upload = async (file?: File) => { if (!file) return; setError(""); setBusy("upload"); try { const result = await onUpload(file); setSelectedRefs(current => [...current, { id: crypto.randomUUID(), title: file.name, url: result.url, type: result.type, duration: result.duration }].slice(0, 6)); } catch (cause) { setError(cause instanceof Error ? cause.message : "上传失败"); } finally { setBusy(null); } }; const chooseGenerationModel = (kind: "image" | "video", value: string) => { if (kind === "image") setImageModelRef(value); else setVideoModelRef(value); if (active) updateSession({ ...active, [kind === "image" ? "imageModelRef" : "videoModelRef"]: value || undefined }); setModelOpen(false); }; const chooseGenerationPolicy = (value: boolean) => { setAutoGenerate(value); setPolicyOpen(false); try { localStorage.setItem(`tvstar:auto:${canvasId}`, value ? "auto" : "manual"); } catch { /* optional */ } if (active) updateSession({ ...active, autoGenerate: value }); }; const copyText = async (value: string, success: string) => { try { if (!navigator.clipboard?.writeText) throw new Error("当前浏览器不支持剪贴板"); await navigator.clipboard.writeText(value); setError(""); setHeaderMenu(null); toast.success(success); } catch { setError("复制失败,请检查浏览器剪贴板权限"); } }; const exportConversation = () => { if (!active) return; const content = [`# ${active.title}`, `创作目标:${active.goal}`, "", ...(active.messages || []).map(item => `${item.role === "user" ? "我" : "TVstar"}:${item.content}`)].join("\n\n"); const blobUrl = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" })); const link = document.createElement("a"); link.href = blobUrl; link.download = `TVstar-${active.title.replace(/[\\/:*?"<>|]/g, "_").slice(0, 30) || "对话"}.txt`; link.click(); window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); setHeaderMenu(null); }; const createShareLink = async () => { if (!active || shareBusy) return; setShareBusy(true); try { const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions/share`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session: { ...active, updatedAt: active.updatedAt || active.createdAt } }), }); const result = await response.json(); if (!response.ok || typeof result.token !== "string") throw new Error(result.error || "生成分享链接失败"); updateSession({ ...active, shareToken: result.token }); setShareConfirm(false); setError(""); toast.success("只读分享链接已生成"); } catch (cause) { setError(cause instanceof Error ? cause.message : "生成分享链接失败"); } finally { setShareBusy(false); } }; const revokeShareLink = async () => { if (!active || shareBusy) return; setShareBusy(true); try { const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions/share?sessionId=${encodeURIComponent(active.id)}`, { method: "DELETE" }); if (!response.ok) throw new Error("关闭分享失败"); updateSession({ ...active, shareToken: undefined }); toast.success("分享链接已失效"); } catch (cause) { setError(cause instanceof Error ? cause.message : "关闭分享失败"); } finally { setShareBusy(false); } }; const toggleDock = () => { onDockChange(!docked); setHeaderMenu(null); }; const startDockResize = (event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); const onMove = (moveEvent: PointerEvent) => { onDockWidthChange(Math.round(Math.min(560, Math.max(320, window.innerWidth - moveEvent.clientX)))); }; const onEnd = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onEnd); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onEnd, { once: true }); }; const toggleNotifications = async () => { if (notificationsEnabled) { setNotificationsEnabled(false); setNotificationSound(false); try { localStorage.setItem(`tvstar:notifications:${canvasId}`, "0"); } catch { /* optional */ } try { localStorage.setItem(`tvstar:notification-sound:${canvasId}`, "0"); } catch { /* optional */ } return; } if (typeof Notification === "undefined") { setError("当前浏览器不支持桌面通知"); return; } const permission = await Notification.requestPermission(); if (permission !== "granted") { setError("浏览器通知未获授权"); return; } setNotificationsEnabled(true); try { localStorage.setItem(`tvstar:notifications:${canvasId}`, "1"); } catch { /* optional */ } }; const toggleNotificationSound = async () => { if (!notificationsEnabled) return; const next = !notificationSound; if (next) { try { const context = audioContextRef.current || new AudioContext(); audioContextRef.current = context; await context.resume(); } catch { setError("浏览器未允许播放通知声音"); return; } } setNotificationSound(next); try { localStorage.setItem(`tvstar:notification-sound:${canvasId}`, next ? "1" : "0"); } catch { /* optional */ } }; return (
{docked ?
: null}
{active ? active.title : "新对话"}
{headerMenu === "share" ?

导出与分享

{active?.shareToken ? <> : shareConfirm ?

任何获得链接的人都能阅读当前对话快照、目标和分镜。正文中的链接也会显示;画布编辑权限不会开放。

: }

分享链接可公开阅读创建时的快照;画布地址仍需访问权限。

: null} {headerMenu === "settings" && typeof document !== "undefined" ? createPortal(
setHeaderMenu(null)}>
event.stopPropagation()} role="dialog">

TVstar 全局设置

项目积分用量提醒需要逐项目的准确结算数据,当前尚不可用。

, document.body) : null} {historyOpen ?
{sessions.length ? sessions.map(item => ) :

暂无历史对话

} {syncError ?

云端同步暂不可用;当前对话仍保存在此浏览器。

: null}
: null}
{!active ?

让 TVstar 辅助你的无限创意

从创意规划到自动生成可播放视频

{[ ["选择 Skill 开始创作", () => setTemplatesOpen(true)], ["从爆款预设开始剧本原创", () => { setMode("script"); setTemplatesOpen(true); }], ["上传故事来改编", () => { setMode("script"); fileRef.current?.click(); }], ["批量创建分镜", () => setMode("storyboard")], ["从已有视频选片剪辑", () => { setMode("edit"); setReferencesOpen(true); }], ].map(([label, action]) => )}
:
{(active.messages || []).length ?
{active.messages?.map(item =>

{item.content}

)}
: null} {active.groupId ?

{productionStatus}

{!active.preparedOnly ?

{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成

:

画布节点已保存,尚未启动模型或扣费

}{finalUrl ?
: null}

创作目标

{active.goal}

{active.plan ?
创作规划

{active.plan.summary}

    {active.plan.agentPlan.map((step, index) =>
  1. {String(index + 1).padStart(2, "0")}{step}
  2. )}
{active.plan.deliverables.length ?

交付物 · {active.plan.deliverables.join(" / ")}

: null}
: null} {active.shots?.length ?

分镜脚本 · {active.shots.length} 镜头

{active.shots.map((shot, index) =>
镜头 {shot.shot} · {shot.scene || "场景"}{shot.duration}

{shot.sceneDescription}

{shot.shotSize} · {shot.cameraMovement}

)}
: null} {(active.plan || active.pendingStage) && !active.shots && active.mode !== "brainstorm" && active.mode !== "edit" ? : null} {active.shots?.length ? : null} {active.mode === "edit" && active.references?.some(item => item.type === "video") ? : null}
}
{busy && busy !== "upload" ?

{{ plan: "正在理解目标并规划制作", shots: "正在生成可执行分镜", submit: "正在提交画布视频任务" }[busy]}

: null} {error ?

{error}

: null} {templatesOpen ?
setSkillSearch(event.target.value)} placeholder="搜索商业广告、剧情或素材改编" value={skillSearch} />
{templates.filter(item => [item.name, item.category, item.description].join(" ").toLowerCase().includes(skillSearch.trim().toLowerCase())).map(item => )}
: null} {referencesOpen ?

引用画布素材

{references.length ? references.map(item => ) :

画布上暂无可引用的素材

}
: null} {mention !== undefined ?
{mentionNodeMatches.length ? <>

画布节点

{mentionNodeMatches.map(item => )} : null}{mentionMatches.length ? <>

素材

{mentionMatches.map(item => )} : null}{mentionModelMatches.length ? <>

模型

{mentionModelMatches.map(item => )} : null}{!mentionNodeMatches.length && !mentionMatches.length && !mentionModelMatches.length ?

没有匹配的节点、素材或模型

: null}
: null}