825 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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");
}
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<string | false>;
onPrepare: (shots: TVstarShot[], goal: string, references: TVstarReference[], selection: TVstarGenerationSelection) => Promise<string | false>;
onRunPrepared: (groupId: string) => Promise<string | false>;
onEdit: (references: TVstarReference[], goal: string) => Promise<string | false>;
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<Session[]>([]);
const sessionsRef = useRef<Session[]>([]);
const [syncReady, setSyncReady] = useState(false);
const [syncError, setSyncError] = useState(false);
const [shareConfirm, setShareConfirm] = useState(false);
const [shareBusy, setShareBusy] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const [goal, setGoal] = useState("");
const [mode, setMode] = useState<Mode>("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<AudioContext | null>(null);
const notifiedTaskRef = useRef<string | null>(null);
const [referencesOpen, setReferencesOpen] = useState(false);
const [selectedRefs, setSelectedRefs] = useState<TVstarReference[]>([]);
const [selectedContextNodes, setSelectedContextNodes] = useState<TVstarNodeContext[]>([]);
const lastSelectedCanvasNodeId = useRef<string | null>(null);
const [restoredDraftFor, setRestoredDraftFor] = useState("");
const [busy, setBusy] = useState<"plan" | "shots" | "submit" | "upload" | null>(null);
const [error, setError] = useState("");
const fileRef = useRef<HTMLInputElement>(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 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";
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 && Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : undefined;
if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试");
item = {
...item,
plan: directShots ? undefined : {
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) : [],
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 = await runAnalysis("story-script", [input, item.plan?.summary,
item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []);
const generated = Array.isArray(storyboard.analysisRows) ? storyboard.analysisRows.filter(isShot).slice(0, 12) : [];
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 = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : [];
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 = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : [];
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<HTMLDivElement>) => {
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 (
<section aria-label="TVstar 创作助手" className={`fixed z-[100] flex flex-col overflow-hidden border border-white/10 bg-[#1c1c1c] text-white shadow-[0_24px_90px_rgba(0,0,0,.65)] ${docked ? "bottom-0 right-0 top-0 h-screen max-w-[calc(100vw-60px)] rounded-none" : "bottom-4 right-4 h-[min(870px,calc(100vh-40px))] w-[min(540px,calc(100vw-24px))] rounded-[22px]"}`} data-canvas-overlay="true" style={docked ? { width: dockWidth } : undefined}>
{docked ? <div aria-label="调整助手宽度" className="absolute bottom-0 left-0 top-0 z-30 w-1 cursor-ew-resize hover:bg-cyan-300/50" onPointerDown={startDockResize} role="separator" /> : null}
<header className="flex h-12 shrink-0 items-center justify-between px-4">
<span className="min-w-0 truncate text-[13px] font-medium">{active ? active.title : "新对话"}</span>
<div className="ml-2 flex shrink-0 items-center gap-0.5 text-white/55">
<button aria-label="新对话" className="rounded-lg p-1.5 hover:bg-white/10 disabled:opacity-40" disabled={!active || Boolean(busy)} onClick={newSession} title="新对话"><Plus className="size-4" /></button>
<button aria-label="历史对话" aria-expanded={historyOpen} className="rounded-lg p-1.5 hover:bg-white/10 disabled:opacity-40" disabled={Boolean(busy)} onClick={() => { setHeaderMenu(null); setHistoryOpen(value => !value); }} title="历史对话"><Clock3 className="size-4" /></button>
<button aria-label={active ? "分享" : "新对话无法分享"} aria-expanded={headerMenu === "share"} className="rounded-lg p-1.5 hover:bg-white/10 disabled:opacity-35" disabled={!active} onClick={() => { setHistoryOpen(false); setHeaderMenu(value => value === "share" ? null : "share"); }} title={active ? "分享" : "新对话无法分享"}><Share2 className="size-4" /></button>
<button aria-label="TVstar 全局设置" aria-expanded={headerMenu === "settings"} className="rounded-lg p-1.5 hover:bg-white/10" onClick={() => { setHistoryOpen(false); setHeaderMenu(value => value === "settings" ? null : "settings"); }} title="TVstar 全局设置"><SlidersHorizontal className="size-4" /></button>
<button aria-label="TVstar Plugin" className="rounded-lg p-1.5 hover:bg-white/10" onClick={onOpenPlugin} title="TVstar Plugin"><Plug className="size-4" /></button>
<button aria-label={docked ? "恢复浮窗" : "停靠到右侧"} className="rounded-lg p-1.5 hover:bg-white/10" onClick={toggleDock} title={docked ? "恢复浮窗" : "停靠到右侧"}><PanelRight className="size-4" /></button>
<button aria-label="收起 TVstar" className="rounded-lg p-1.5 hover:bg-white/10" onClick={onClose} title="收起 TVstar"><Minus className="size-4" /></button>
</div>
</header>
{headerMenu === "share" ? <div aria-label="TVstar 分享菜单" className="absolute right-3 top-12 z-20 w-64 rounded-xl border border-white/10 bg-[#292929] p-2 shadow-xl">
<p className="px-2 py-1 text-xs text-white/50">导出与分享</p>
{active?.shareToken ? <>
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => void copyText(new URL(`/tvstar/share/${active.shareToken}`, window.location.origin).toString(), "只读分享链接已复制")}><Share2 className="size-4" />复制只读分享链接</button>
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs text-rose-300 hover:bg-white/10 disabled:opacity-40" disabled={shareBusy} onClick={() => void revokeShareLink()}>关闭公开分享</button>
</> : shareConfirm ? <div className="rounded-lg border border-amber-400/20 bg-amber-400/5 p-2 text-[11px] leading-5 text-amber-100/80">
<p>任何获得链接的人都能阅读当前对话快照、目标和分镜。正文中的链接也会显示;画布编辑权限不会开放。</p>
<div className="mt-2 flex justify-end gap-2"><button className="rounded px-2 py-1 hover:bg-white/10" onClick={() => setShareConfirm(false)}>取消</button><button className="rounded bg-white px-2 py-1 text-black disabled:opacity-50" disabled={shareBusy} onClick={() => void createShareLink()}>{shareBusy ? "生成中…" : "确认生成"}</button></div>
</div> : <button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => setShareConfirm(true)}><Share2 className="size-4" />生成只读分享链接</button>}
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10 disabled:opacity-40" disabled={!active} onClick={() => void copyText((active?.messages || []).map(item => `${item.role === "user" ? "我" : "TVstar"}:${item.content}`).join("\n\n"), "对话记录已复制") }><Copy className="size-4" />复制对话记录</button>
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10 disabled:opacity-40" disabled={!active} onClick={exportConversation}><Download className="size-4" />下载对话记录</button>
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => void copyText(window.location.href, "画布地址已复制")}><Share2 className="size-4" />复制画布地址</button>
<p className="px-2 py-1 text-[11px] leading-4 text-white/35">分享链接可公开阅读创建时的快照;画布地址仍需访问权限。</p>
</div> : null}
{headerMenu === "settings" && typeof document !== "undefined" ? createPortal(<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/65 p-4" onClick={() => setHeaderMenu(null)}>
<div aria-label="TVstar 全局设置" aria-modal="true" className="w-full max-w-[460px] rounded-2xl border border-white/15 bg-[#252525] p-5 shadow-[0_30px_100px_rgba(0,0,0,.65)]" onClick={event => event.stopPropagation()} role="dialog">
<div className="mb-4 flex items-center justify-between"><h2 className="text-base font-medium">TVstar 全局设置</h2><button aria-label="关闭设置" className="rounded-lg px-2 py-1 text-lg text-white/50 hover:bg-white/10" onClick={() => setHeaderMenu(null)}>×</button></div>
<button aria-pressed={autoGenerate} className="flex w-full items-center justify-between rounded-xl px-2 py-3 text-left text-sm hover:bg-white/10" onClick={() => chooseGenerationPolicy(!autoGenerate)}><span>自动执行视频工作流<br /><span className="text-xs text-white/40">根据创作目标提交已配置的图片与视频模型任务</span></span><span className={autoGenerate ? "text-cyan-200" : "text-white/40"}>{autoGenerate ? "已开启" : "已关闭"}</span></button>
<button aria-pressed={notificationsEnabled} className="flex w-full items-center justify-between rounded-xl px-2 py-3 text-left text-sm hover:bg-white/10" onClick={() => void toggleNotifications()}><span>成片完成通知<br /><span className="text-xs text-white/40">浏览器授权后发送桌面通知</span></span><span className={notificationsEnabled ? "text-cyan-200" : "text-white/40"}>{notificationsEnabled ? "已开启" : "已关闭"}</span></button>
<button aria-pressed={notificationSound} className="flex w-full items-center justify-between rounded-xl px-2 py-3 text-left text-sm hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-35" disabled={!notificationsEnabled} onClick={() => void toggleNotificationSound()}><span>通知声音<br /><span className="text-xs text-white/40">成片完成时播放提示音</span></span><span className={notificationSound ? "text-cyan-200" : "text-white/40"}>{notificationSound ? "已开启" : "已关闭"}</span></button>
<p className="mt-4 border-t border-white/10 pt-4 text-xs leading-5 text-white/40">项目积分用量提醒需要逐项目的准确结算数据,当前尚不可用。</p>
</div>
</div>, document.body) : null}
{historyOpen ? <div className="absolute inset-x-3 top-12 z-10 max-h-72 overflow-y-auto rounded-xl border border-white/10 bg-[#292929] p-2 shadow-xl">
{sessions.length ? sessions.map(item => <button className="block w-full rounded-lg px-3 py-2 text-left text-[13px] hover:bg-white/10" key={item.id} onClick={() => { selectSession(item.id); setHistoryOpen(false); }}><span className="block truncate">{item.title}</span><span className="text-[11px] text-white/40">{new Date(item.createdAt).toLocaleString("zh-CN")}</span></button>) : <p className="p-3 text-xs text-white/45">暂无历史对话</p>}
{syncError ? <p className="p-2 text-[11px] text-amber-200/70">云端同步暂不可用;当前对话仍保存在此浏览器。</p> : null}
</div> : null}
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
{!active ? <div className="flex min-h-full flex-col justify-center pb-3">
<div className="mb-6 text-center"><div className="mx-auto mb-4 size-12 rounded-full border border-cyan-100/60 bg-[radial-gradient(circle_at_35%_32%,#8df9f2_0%,#39dce9_24%,#579cff_53%,#7259d6_78%,#2a647c_100%)] shadow-[0_0_24px_rgba(90,206,245,.6)]" /><h2 className="text-[16px] font-medium">让 TVstar 辅助你的无限创意</h2><p className="mt-2 text-xs text-white/40">从创意规划到自动生成可播放视频</p></div>
<div className="space-y-1">{[
["选择 Skill 开始创作", () => setTemplatesOpen(true)],
["从爆款预设开始剧本原创", () => { setMode("script"); setTemplatesOpen(true); }],
["上传故事来改编", () => { setMode("script"); fileRef.current?.click(); }],
["批量创建分镜", () => setMode("storyboard")],
["从已有视频选片剪辑", () => { setMode("edit"); setReferencesOpen(true); }],
].map(([label, action]) => <button className="flex h-10 w-full items-center justify-between rounded-xl px-3 text-left text-[13px] text-white/60 hover:bg-white/5 hover:text-white" key={label as string} onClick={action as () => void}><span><Sparkles className="mr-2 inline size-3.5" />{label as string}</span><span>→</span></button>)}</div>
</div> : <div className="space-y-4 pt-4">
{(active.messages || []).length ? <div aria-label="TVstar 对话记录" className="space-y-2">{active.messages?.map(item => <p className={`max-w-[92%] whitespace-pre-wrap rounded-xl px-3 py-2 text-xs leading-5 ${item.role === "user" ? "ml-auto bg-[#343b48] text-white" : "bg-[#292929] text-white/70"}`} key={item.id}>{item.content}</p>)}</div> : null}
{active.groupId ? <div aria-label="TVstar 制作进度" className={`${panel} p-3`}><p className="text-xs text-cyan-200" role="status">{productionStatus}</p>{!active.preparedOnly ? <p className="mt-1 text-[11px] text-white/45">{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成</p> : <p className="mt-1 text-[11px] text-white/45">画布节点已保存,尚未启动模型或扣费</p>}{finalUrl ? <video className="mt-3 w-full rounded-lg bg-black" controls playsInline preload="metadata" src={finalUrl} /> : null}{finalTask?.error ? <p className="mt-2 text-xs text-red-200">{finalTask.error}</p> : null}</div> : null}
<div className={`${panel} p-3`}><p className="mb-2 text-[11px] text-white/40">创作目标</p><p className="whitespace-pre-wrap text-[13px] leading-6">{active.goal}</p></div>
{active.plan ? <div className={`${panel} p-4`}><div className="mb-3 flex items-center gap-2 text-[13px] font-medium"><Sparkles className="size-4 text-cyan-300" />创作规划</div><p className="mb-3 text-[13px] leading-6 text-white/75">{active.plan.summary}</p><ol className="space-y-2">{active.plan.agentPlan.map((step, index) => <li className="flex gap-2 text-xs leading-5 text-white/70" key={`${index}-${step}`}><span className="text-cyan-300">{String(index + 1).padStart(2, "0")}</span><span>{step}</span></li>)}</ol>{active.plan.deliverables.length ? <p className="mt-4 border-t border-white/10 pt-3 text-[11px] text-white/45">交付物 · {active.plan.deliverables.join(" / ")}</p> : null}</div> : null}
{active.shots?.length ? <div className="space-y-2"><h3 className="text-[13px] font-medium">分镜脚本 · {active.shots.length} 镜头</h3>{active.shots.map((shot, index) => <article className={`${panel} p-3`} key={`${index}-${shot.shot}`}><div className="mb-1 flex items-center justify-between text-xs"><span className="text-white/90">镜头 {shot.shot} · {shot.scene || "场景"}</span><span className="text-white/40">{shot.duration}</span></div><p className="text-xs leading-5 text-white/60">{shot.sceneDescription}</p><p className="mt-2 text-[11px] text-cyan-200/70">{shot.shotSize} · {shot.cameraMovement}</p></article>)}</div> : null}
{(active.plan || active.pendingStage) && !active.shots && active.mode !== "brainstorm" && active.mode !== "edit" ? <button className="w-full rounded-xl bg-white px-4 py-2.5 text-[13px] font-medium text-[#1c1c1c] disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void generateShots()}>{busy === "shots" ? "正在生成分镜…" : "继续生成分镜"}</button> : null}
{active.shots?.length ? <button className="w-full rounded-xl bg-white px-4 py-2.5 text-[13px] font-medium text-[#1c1c1c] disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void submitExisting()}>{busy === "submit" ? "正在提交视频制作…" : active.preparedOnly ? "运行画布中的待制作工作流" : active.runSubmitted ? "再次制作视频" : "生成视频并合成成片"}</button> : null}
{active.mode === "edit" && active.references?.some(item => item.type === "video") ? <button className="w-full rounded-xl bg-white px-4 py-2.5 text-[13px] font-medium text-[#1c1c1c] disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void submitEditExisting()}>{active.runSubmitted ? "再次剪辑已有视频" : "开始剪辑已有视频"}</button> : null}
</div>}
</div>
<div className="shrink-0 border-t border-white/10 p-3">
{busy && busy !== "upload" ? <p className="mb-2 px-2 text-xs text-cyan-200" role="status">{{ plan: "正在理解目标并规划制作", shots: "正在生成可执行分镜", submit: "正在提交画布视频任务" }[busy]}</p> : null}
{error ? <p className="mb-2 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-200" role="alert">{error}</p> : null}
{templatesOpen ? <div aria-label="TVstar Skill" className="mb-2 rounded-xl border border-white/10 bg-[#292929] p-2"><input aria-label="搜索 Skill" className="mb-2 w-full rounded-lg border border-white/10 bg-[#202020] px-2 py-1.5 text-xs outline-none" onChange={event => setSkillSearch(event.target.value)} placeholder="搜索商业广告、剧情或素材改编" value={skillSearch} /><div className="max-h-48 space-y-0.5 overflow-y-auto">{templates.filter(item => [item.name, item.category, item.description].join(" ").toLowerCase().includes(skillSearch.trim().toLowerCase())).map(item => <button className="block w-full rounded-lg px-2 py-2 text-left hover:bg-white/10" key={item.name} onClick={() => { if (active) selectSession(null); setGoal(item.prompt); setTemplatesOpen(false); setSkillSearch(""); }}><span className="block text-xs text-white/85">{item.name} <span className="text-[10px] text-white/35">· {item.category}</span></span><span className="text-[10px] text-white/45">{item.description}</span></button>)}</div></div> : null}
{referencesOpen ? <div className="mb-2 max-h-36 overflow-y-auto rounded-xl border border-white/10 bg-[#292929] p-2"><p className="px-2 pb-1 text-[11px] text-white/40">引用画布素材</p>{references.length ? references.map(item => <button className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setSelectedRefs(current => current.some(ref => ref.id === item.id) ? current.filter(ref => ref.id !== item.id) : [...current, item].slice(0, 6)); }}><span className="w-4">{selectedRefs.some(ref => ref.id === item.id) ? <Check className="size-3.5" /> : null}</span><span className="truncate">{item.title}</span></button>) : <p className="px-2 py-1 text-xs text-white/40">画布上暂无可引用的素材</p>}</div> : null}
{mention !== undefined ? <div aria-label="@ 引用画布节点、素材和模型" className="mb-2 max-h-48 overflow-y-auto rounded-xl border border-white/10 bg-[#292929] p-2">{mentionNodeMatches.length ? <><p className="px-2 py-1 text-[10px] text-white/35">画布节点</p>{mentionNodeMatches.map(item => <button className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setSelectedContextNodes(current => [...current, item].slice(0, 6)); const media = references.find(ref => ref.id === item.id); if (media) setSelectedRefs(current => current.some(ref => ref.id === media.id) ? current : [...current, media].slice(0, 6)); setGoal(current => current.replace(/(^|\s)@[^@\s]*$/, "$1")); }}>{item.type} · {item.title}</button>)}</> : null}{mentionMatches.length ? <><p className="px-2 py-1 text-[10px] text-white/35">素材</p>{mentionMatches.map(item => <button className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setSelectedRefs(current => [...current, item].slice(0, 6)); setGoal(current => current.replace(/(^|\s)@[^@\s]*$/, "$1")); }}>{item.type === "video" ? "视频" : "图片"} · {item.title}</button>)}</> : null}{mentionModelMatches.length ? <><p className="px-2 py-1 text-[10px] text-white/35">模型</p>{mentionModelMatches.map(item => <button className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={`${item.kind}-${item.id}`} onClick={() => { chooseGenerationModel(item.kind, item.id); setGoal(current => current.replace(/(^|\s)@[^@\s]*$/, "$1")); }}>{item.kind === "video" ? "视频" : "图片"} · {item.label}</button>)}</> : null}{!mentionNodeMatches.length && !mentionMatches.length && !mentionModelMatches.length ? <p className="px-2 py-1 text-xs text-white/40">没有匹配的节点、素材或模型</p> : null}</div> : null}
<div className="rounded-2xl border border-white/10 bg-[#242424] p-2">
<textarea aria-label="TVstar 创作目标" className="min-h-[66px] w-full resize-none bg-transparent px-1.5 py-1 text-[13px] text-white outline-none placeholder:text-white/35" disabled={Boolean(busy)} onChange={event => setGoal(event.target.value)} onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); void createSession(); } }} placeholder={active ? "继续提要求,例如修改镜头、节奏或风格;输入 @ 引用素材" : "开始你的创作,或者输入 @ 引用画布素材"} value={goal} />
{selectedRefs.some(ref => !selectedContextNodes.some(node => node.id === ref.id)) ? <div className="mb-2 flex flex-wrap gap-1">{selectedRefs.filter(ref => !selectedContextNodes.some(node => node.id === ref.id)).map(item => <button className="max-w-36 truncate rounded-md bg-white/10 px-2 py-1 text-[10px] text-white/65" key={item.id} onClick={() => setSelectedRefs(current => current.filter(ref => ref.id !== item.id))}>{item.title} ×</button>)}</div> : null}
{selectedContextNodes.length ? <div className="mb-2 flex flex-wrap gap-1">{selectedContextNodes.map(item => <button className="max-w-36 truncate rounded-md bg-white/10 px-2 py-1 text-[10px] text-white/65" key={item.id} onClick={() => { setSelectedContextNodes(current => current.filter(node => node.id !== item.id)); setSelectedRefs(current => current.filter(ref => ref.id !== item.id)); }}>{item.title} ×</button>)}</div> : null}
{imageModelRef || videoModelRef ? <div className="mb-2 flex flex-wrap gap-1">{([ ["image", imageModelRef, "图片"], ["video", videoModelRef, "视频"] ] as const).filter(([, ref]) => ref).map(([kind, ref, label]) => <button className="max-w-40 truncate rounded-md bg-cyan-500/15 px-2 py-1 text-[10px] text-cyan-100" key={kind} onClick={() => chooseGenerationModel(kind, "")}>{label} · {generationModels.find(item => item.id === ref)?.label || ref} ×</button>)}</div> : null}
<div className="flex items-center gap-1">
<input accept="image/*,video/*,.txt,.md" className="hidden" onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; if (file.type.startsWith("text/") || /\.(txt|md)$/i.test(file.name)) { void file.text().then(text => setGoal(current => `${current}${current ? "\n" : ""}${text.slice(0, 12000)}`)); } else { void upload(file); } }} ref={fileRef} type="file" />
<button aria-label="上传故事或素材" className="rounded-lg p-1.5 text-white/60 hover:bg-white/10" disabled={Boolean(busy)} onClick={() => fileRef.current?.click()}><Paperclip className="size-4" /></button>
<button aria-label="引用画布素材" className="rounded-lg p-1.5 text-white/60 hover:bg-white/10" onClick={() => setReferencesOpen(value => !value)}><FilePlus2 className="size-4" /></button>
<div className="relative"><button aria-label="选择创作模式" className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-white/70 hover:bg-white/10" onClick={() => setModeOpen(value => !value)}>{modes.find(item => item.value === mode)?.label}<ChevronDown className="size-3" /></button>{modeOpen ? <div className="absolute bottom-9 left-0 z-20 w-52 rounded-xl border border-white/10 bg-[#303030] p-1 shadow-xl">{modes.map(item => <button className="block w-full rounded-lg px-2 py-2 text-left hover:bg-white/10" key={item.value} onClick={() => { setMode(item.value); setModeOpen(false); }}><span className="block text-xs">{item.label}</span><span className="text-[10px] text-white/40">{item.description}</span></button>)}</div> : null}</div>
<div className="relative"><button aria-label="选择生成模型" className="max-w-20 truncate rounded-lg px-2 py-1.5 text-xs text-white/60 hover:bg-white/10" onClick={() => setModelOpen(value => !value)}>选择模型</button>{modelOpen ? <div aria-label="TVstar 模型选择" className="absolute bottom-9 right-0 z-20 w-64 rounded-xl border border-white/10 bg-[#303030] p-2 shadow-xl"><div className="mb-1 flex gap-1">{([ ["image", "图片"], ["video", "视频"], ["planning", "规划"] ] as const).map(([tab, label]) => <button className={`rounded-md px-2 py-1 text-xs ${modelTab === tab ? "bg-white/15 text-white" : "text-white/50"}`} key={tab} onClick={() => setModelTab(tab)}>{label}</button>)}</div><div className="max-h-52 overflow-y-auto"><button className="block w-full rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => { if (modelTab === "planning") { setModelRef(""); setModelOpen(false); } else chooseGenerationModel(modelTab, ""); }}>自动选择</button>{modelTab === "planning" ? models.filter(item => !selectedRefs.length || item.supportsVision).map(item => <button className="block w-full truncate rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setModelRef(item.id); setModelOpen(false); }}>{item.label}</button>) : generationModels.filter(item => item.kind === modelTab && (modelTab !== "image" || !(selectedRefs.length || active?.references?.length) || item.supportsReference !== false)).map(item => <button className="block w-full truncate rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => chooseGenerationModel(modelTab, item.id)}>{item.label}</button>)}</div></div> : null}</div>
<div className="relative"><button aria-label="选择制作策略" className="whitespace-nowrap rounded-lg px-1.5 py-1.5 text-[11px] text-white/60 hover:bg-white/10" onClick={() => setPolicyOpen(value => !value)}>{autoGenerate ? "自动制作" : "先确认"}</button>{policyOpen ? <div className="absolute bottom-9 right-0 z-20 w-52 rounded-xl border border-white/10 bg-[#303030] p-1 shadow-xl"><button className="block w-full rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => chooseGenerationPolicy(true)}>自动制作 · 分镜完成后提交任务</button><button className="block w-full rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => chooseGenerationPolicy(false)}>先确认 · 查看分镜后再制作</button></div> : null}</div>
<button className="ml-auto flex size-8 items-center justify-center rounded-full bg-white text-black disabled:opacity-40" disabled={!goal.trim() || Boolean(busy)} onClick={() => void createSession()} title="发送创作目标">{busy ? <Loader2 className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}</button>
</div>
</div>
</div>
</section>
);
}