825 lines
65 KiB
TypeScript
825 lines
65 KiB
TypeScript
"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>
|
||
);
|
||
}
|