diff --git a/app/(chat)/video-canvas/page.tsx b/app/(chat)/video-canvas/page.tsx index dfffb5b4c..de9767554 100644 --- a/app/(chat)/video-canvas/page.tsx +++ b/app/(chat)/video-canvas/page.tsx @@ -1,79 +1,75 @@ -import { randomUUID } from "node:crypto"; import { redirect } from "next/navigation"; -import { auth } from "@/app/(auth)/auth"; -import { db } from "@/lib/db"; -import { canvasProject } from "@/lib/db/schema"; import { and, desc, eq } from "drizzle-orm"; +import { auth } from "@/app/(auth)/auth"; +import { VideoCanvasHome, type VideoCanvasHomeProject } from "@/components/canvas/VideoCanvasHome"; +import { db } from "@/lib/db"; +import { getSiteSettings } from "@/lib/db/queries"; +import { canvasProject } from "@/lib/db/schema"; -type VideoCanvasRedirectPageProps = { - searchParams?: Promise>; -}; +type Props = { searchParams?: Promise> }; -function getSearchParam( - params: Record, - key: string -) { - const value = params[key]; +function first(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value; } -function buildVideoCanvasUrl( - canvasId: string, - params: Record -) { - const query = new URLSearchParams(); - query.set("mode", "video"); - query.set("workspaceType", "video"); - - for (const [key, value] of Object.entries(params)) { - if ( - key === "new" || - key === "mode" || - key === "workspaceType" || - value === undefined - ) { - continue; - } - if (Array.isArray(value)) { - value.forEach((item) => query.append(key, item)); - continue; - } - query.set(key, value); +function canvasHref(id: string, incoming: Record) { + const params = new URLSearchParams({ mode: "video", workspaceType: "video" }); + for (const [key, value] of Object.entries(incoming)) { + if (["new", "mode", "workspaceType"].includes(key) || value === undefined) continue; + for (const item of Array.isArray(value) ? value : [value]) params.append(key, item); } - - return `/canvas/${canvasId}?${query.toString()}`; + return `/canvas/${id}?${params.toString()}`; } -async function getLatestVideoCanvasId(userId: string) { - const [latestProject] = await db - .select({ id: canvasProject.id }) - .from(canvasProject) - .where( - and( - eq(canvasProject.userId, userId), - eq(canvasProject.workspaceType, "video") - ) - ) - .orderBy(desc(canvasProject.updatedAt)) - .limit(1); - - return latestProject?.id || null; +function projectPreview(thumbnail: string | null, canvasData: unknown) { + const nodes = canvasData && typeof canvasData === "object" && "nodes" in canvasData + ? (canvasData as { nodes?: unknown }).nodes : null; + const candidates = [thumbnail]; + if (Array.isArray(nodes)) for (const node of [...nodes].reverse()) { + if (!node || typeof node !== "object") continue; + const item = node as { inputType?: string; draftValues?: Record }; + const draft = item.draftValues || {}; + candidates.push( + String(draft.poster || draft.previewImageUrl || draft.thumbnailUrl || draft.resultImage || (item.inputType === "image" ? draft.mediaUrl || (Array.isArray(draft.url) ? draft.url[0] : "") : "") || ""), + ); + } + return candidates.find(value => typeof value === "string" && (value.startsWith("https://") || value.startsWith("/"))) || null; } -export default async function VideoCanvasRedirectPage({ - searchParams, -}: VideoCanvasRedirectPageProps) { - const params = (await searchParams) || {}; - const shouldCreateNew = getSearchParam(params, "new") === "1"; - - if (shouldCreateNew) { - redirect(buildVideoCanvasUrl(randomUUID(), params)); - } - +export default async function VideoCanvasHomePage({ searchParams }: Props) { const session = await auth(); - const latestCanvasId = session?.user?.id - ? await getLatestVideoCanvasId(session.user.id).catch(() => null) - : null; + if (!session?.user?.id) redirect("/login?callbackUrl=%2Fvideo-canvas"); - redirect(buildVideoCanvasUrl(latestCanvasId || randomUUID(), params)); + const params = (await searchParams) || {}; + if (first(params.new) === "1") { + const [created] = await db.insert(canvasProject).values({ + userId: session.user.id, + title: "未命名视频画布", + workspaceType: "video", + canvasData: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, + }).returning({ id: canvasProject.id }); + redirect(canvasHref(created.id, params)); + } + + const [rows, siteSettings] = await Promise.all([db.select({ + id: canvasProject.id, + title: canvasProject.title, + thumbnail: canvasProject.thumbnail, + canvasData: canvasProject.canvasData, + createdAt: canvasProject.createdAt, + updatedAt: canvasProject.updatedAt, + }).from(canvasProject).where(and( + eq(canvasProject.userId, session.user.id), + eq(canvasProject.workspaceType, "video"), + )).orderBy(desc(canvasProject.updatedAt)).limit(25), getSiteSettings()]); + + const projects: VideoCanvasHomeProject[] = rows.slice(0, 24).map(row => ({ + id: row.id, + title: row.title, + thumbnail: projectPreview(row.thumbnail, row.canvasData), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })); + + return 24} initialProjects={projects} siteLogo={siteSettings.siteLogo} siteLogoDark={siteSettings.siteLogoDark} siteName={siteSettings.siteName} userName={session.user.name || ""} />; } diff --git a/app/api/canvas/media/upload/route.ts b/app/api/canvas/media/upload/route.ts index 6cd31303d..b00fa6a09 100644 --- a/app/api/canvas/media/upload/route.ts +++ b/app/api/canvas/media/upload/route.ts @@ -33,15 +33,30 @@ export async function POST(request: Request) { const input = join(dir, "input"); const buffer = await file.arrayBuffer(); await writeFile(input, Buffer.from(buffer)); - const { stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm,mp3,wav,ogg,flac", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 }); + let stdout: string; + try { + ({ stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm,mp3,wav,ogg,flac", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 })); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return NextResponse.json({ success: false, error: "服务器缺少 ffprobe 视频解析组件,请联系管理员安装 FFmpeg" }, { status: 503 }); + } + return NextResponse.json({ success: false, error: "视频文件无法解析,请确认素材完整且格式受支持" }, { status: 400 }); + } const info = JSON.parse(stdout); const kind = file.type.startsWith("video/") ? "video" : "audio"; const stream = info.streams?.find((item: { codec_type: string }) => item.codec_type === kind); const duration = Number(info.format?.duration); if (!stream || !Number.isFinite(duration) || duration <= 0 || duration > 600) return NextResponse.json({ success: false, error: "请上传内容完整、10 分钟以内的音视频" }, { status: 400 }); - const uploaded = await uploadFile(`canvas-${randomUUID()}.${formats[file.type]}`, buffer); + let uploaded; + try { + uploaded = await uploadFile(`canvas-${randomUUID()}.${formats[file.type]}`, buffer); + } catch (error) { + console.error("[Canvas media upload] Storage unavailable:", error); + return NextResponse.json({ success: false, error: "媒体存储暂不可用,请稍后重试" }, { status: 503 }); + } return NextResponse.json({ success: true, data: { url: uploaded.url, duration, width: stream.width, height: stream.height } }); - } catch { + } catch (error) { + console.error("[Canvas media upload] Failed:", error); return NextResponse.json({ success: false, error: "素材上传失败,请确认文件完整或稍后重试" }, { status: 400 }); } finally { running.delete(session.user.id); diff --git a/app/globals.css b/app/globals.css index aab366f98..83509dd95 100644 --- a/app/globals.css +++ b/app/globals.css @@ -408,6 +408,15 @@ } /* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */ +.video-canvas-workspace { + /* Match the compact typography used by the reference canvas. */ + --canvas-font-caption: 12px; + --canvas-font-body: 13px; + --canvas-font-heading: 14px; + font-size: var(--canvas-font-body); + line-height: 1.45; +} + .settings-workspace { font-size: 13px; line-height: 1.45; diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index ba593872b..f19a2c7a3 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -9,7 +9,7 @@ import { compileGroupRun } from "@/lib/video-canvas/group-execution"; import { requireResolvedReferenceImages, connectionError, connectionTypeError, videoInputLimitError, connectedText, connectedInputNodes, assertConnectedInputsReady, withConnectedText, connectedImageUrls } from "@/lib/video-canvas/connections"; import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent"; import { SmartEditDialog } from "./smart-edit/SmartEditDialog"; -import { SmartEditNodeCard, SmartEditNodeComposer } from "./smart-edit/SmartEditNode"; +import { SmartEditNodeCard, SmartEditNodeComposer, type SmartEditMode } from "./smart-edit/SmartEditNode"; import { smartEditRequestSchema, type EditPlan } from "@/lib/video-canvas/smart-edit/contract"; import { AudioGenerationComposer } from "./AudioGenerationComposer"; import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice"; @@ -35,6 +35,7 @@ import { BookOpen, BoxSelect, Camera, + Clapperboard, Check, ChevronDown, ChevronRight, @@ -459,8 +460,9 @@ type CanvasModelOption = { -type AddModuleKind = "smart-edit" | "text" | "image" | "video" | "video-clip" | "director" | "audio" | "script" | "legacy-script"; +type AddModuleKind = "smart-edit" | "text" | "image" | "video" | "frame-analysis" | "video-clip" | "director" | "audio" | "script" | "legacy-script"; type CanvasCreateKind = AddModuleKind; +type CanvasViewMode = "workflow" | "storyboard"; type TargetModuleActionLabel = | "自己编写内容" | "文生视频" @@ -477,10 +479,19 @@ type TargetModuleActionLabel = | "自己编写分镜脚本" | "生成高清视频" | "打开智能剪辑" + | "讲解视频" + | "批量广告" + | "口播视频" + | "素材混剪" | "打开导演台" | "剧本生成分镜脚本" | "视频参考生成分镜脚本" - | "角色生成分镜脚本"; + | "角色生成分镜脚本" + | "上传视频拉片" + | "从画布选择视频拉片" + | "拉片分镜" + | "拉片动态" + | "拉片音乐"; type ImageToolbarAction = | "panorama" @@ -744,11 +755,12 @@ const ADD_NODE_MODULES: Array<{ { kind: "text", label: "文本", description: "剧本、广告词、品牌文案", icon: FileText }, { kind: "image", label: "图片", description: "海报、分镜、角色设计", icon: ImageIcon }, { kind: "video", label: "视频", description: "创意广告、动画、电影", icon: Video }, - { kind: "smart-edit", label: "智能剪辑", description: "自动选片、卡点、字幕与成片", icon: Scissors, badge: "NEW" }, - { kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" }, + { kind: "smart-edit", label: "智能剪辑", description: "自动选片、卡点、字幕与成片", icon: Scissors, badge: "Beta" }, { kind: "director", label: "导演台", description: "3D空间多视角截图", icon: Settings2, badge: "NEW" }, + { kind: "frame-analysis", label: "逐帧拉片", description: "上传视频,拆解分镜、动态与音乐", icon: ScanText, badge: "视频解析" }, { kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 }, { kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "NEW" }, + { kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" }, ]; const ADD_NODE_PRIMARY_MODULES = ADD_NODE_MODULES.filter((item) => item.kind !== "script"); @@ -784,6 +796,7 @@ const TARGET_MODULE_CENTER_OFFSETS: Record = [ + { label: "图片生成", kind: "image", icon: ImageIcon }, + { label: "视频生成", kind: "video", icon: Video }, + { label: "音频生成", kind: "audio", icon: Music2 }, + { label: "剧本生成", kind: "script", icon: FileText }, + { label: "智能剪辑", kind: "smart-edit", icon: Scissors }, +]; + const TOOLBOX_TEMPLATE_CATEGORIES = ["周星驰经典名场面", "电商展示", "镜头运动", "人物动画", "全部"] as const; const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [ @@ -1050,7 +1071,7 @@ const TOOLBOX_TEMPLATE_GROUP_FRAME = { const OFFICIAL_TEXT_RESOURCE_HEIGHT = 330; const OFFICIAL_TEXT_RESOURCE_WIDTH = 560; const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7; -const OFFICIAL_COMPOSER_SCALE = 0.546; +const OFFICIAL_COMPOSER_SCALE = 0.73; const OFFICIAL_DROPDOWN_SCALE = 0.7; const OFFICIAL_NINE_GRID_WIDTH = 930; const OFFICIAL_NINE_GRID_HEIGHT = 520; @@ -1567,7 +1588,10 @@ function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index } if (kind === "smart-edit") { - return {id:makeId("smart-edit"),kind:"model",title:`智能剪辑 ${index}`,subtitle:"智能剪辑",inputType:"video",x,y,width:566,height:350,accent:"#ffb8c8",draftValues:{action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit"}}; + return {id:makeId("smart-edit"),kind:"model",title:`智能剪辑 ${index}`,subtitle:"智能剪辑",inputType:"video",x,y,width:350,height:350,accent:"#ffb8c8",draftValues:{action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit",smartEditDraft:{mode:"auto",ratio:"16:9",resolution:"720p",targetDuration:30}}}; + } + if (kind === "frame-analysis") { + return { id: makeId("frame-analysis"), kind: "model", title: "逐帧拉片", subtitle: "视频解析", x, y, width: 350, height: 350, accent: "#67a9bc", draftValues: { action: "FRAME_ANALYSIS", nodeType: "frame-analysis", dimensions: { shots: true, motion: true, music: true } } }; } if (kind === "video-clip") { return { @@ -1613,7 +1637,7 @@ function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index }; } - const meta: Record, { + const meta: Record, { action: string; accent: string; kind: CloneNodeKind; @@ -2086,6 +2110,13 @@ function getNodeReferenceMediaUrl(node: CloneNode | null | undefined) { return getNodeMediaUrl(node) || getDraftString(node, "sourceUrl"); } +function getNodePlayableVideoUrl(node: CloneNode | null | undefined) { + const direct = getDraftString(node, "mediaUrl"); + if (direct) return direct; + const urls = node?.draftValues?.url; + return Array.isArray(urls) && typeof urls[0] === "string" ? urls[0] : ""; +} + function normalizeVideoCanvasMagicMarks(value: unknown): VideoCanvasMagicMark[] { if (!Array.isArray(value)) return []; return value @@ -3160,6 +3191,7 @@ function getConfiguredToolType(definition: VideoCanvasFunctionDefinition) { function functionRequiresMedia(definition: VideoCanvasFunctionDefinition) { if (definition.route === "local") return false; + if (definition.kind === "image") return false; if (definition.key === "text-to-video" || definition.key === "story-script") return false; return ( definition.kind === "image-edit" || @@ -3350,6 +3382,13 @@ function normalizeVideoResolutionValue(value: unknown): VideoResolution { return "720p"; } +function assertExecutableVideoResolution(value: unknown) { + const requested = String(value || "").trim().toLowerCase(); + if (["4k", "2160p", "2k", "1440p"].includes(requested)) { + throw new Error("当前视频生成最高支持 1080P;更高画质请在生成后使用视频高清"); + } +} + function formatVideoResolutionLabel(value: unknown) { const resolution = normalizeVideoResolutionValue(value); return resolution === "480p" ? "480P" : resolution === "1080p" ? "1080P" : "720P"; @@ -4028,6 +4067,7 @@ function getTargetModuleKind(node: CloneNode): AddModuleKind | null { nodeType === "video" || nodeType === "smart-edit" || nodeType === "video-clip" || + nodeType === "frame-analysis" || nodeType === "director" || nodeType === "audio" || nodeType === "script-v2" || @@ -4101,8 +4141,9 @@ function shouldResetTargetModuleTitle(title: string | undefined, kind: AddModule const value = String(title || "").trim(); if (!value) return true; const legacyTitlePattern = - /^(文本|文本生成|文本节点|图片|图片生成|图片节点|视频|视频生成|视频节点|音频|音频生成|音频节点|脚本|脚本生成|脚本生成器|视频合成|视频合成节点|导演台)(\s*\d+)?$/; + /^(文本|文本生成|文本节点|图片|图片生成|图片节点|视频|视频生成|视频节点|音频|音频生成|音频节点|脚本|脚本生成|脚本生成器|视频合成|视频合成节点|逐帧拉片|导演台)(\s*\d+)?$/; if (!legacyTitlePattern.test(value)) return false; + if (kind === "frame-analysis") return value !== "逐帧拉片"; if (kind === "video-clip") return !/^视频合成\s*\d+$/.test(value); if (kind === "director") return !/^导演台\s*\d+$/.test(value); if (kind === "script") return value !== "脚本生成器"; @@ -4466,6 +4507,9 @@ export function LiblibVideoCanvasReplica({ const [fileDragActive, setFileDragActive] = useState(false); const replaceFileInputRef=useRef(null); const replaceFileNodeId=useRef(null); + const pendingFrameAnalysisUploadRef=useRef(null); + const homeStarterAppliedRef = useRef(null); + const homeStarterSavingRef = useRef(false); const connectionDragCleanupRef = useRef<(() => void) | null>(null); const connectionDragRef = useRef(null); const assetManagerResizeCleanupRef = useRef<(() => void) | null>(null); @@ -4509,6 +4553,21 @@ export function LiblibVideoCanvasReplica({ const activeGenerationSubmissions = useRef(new Set()); const [edges, setEdges] = useState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); + useEffect(() => { + setNodes((current) => { + let changed = false; + const next = current.map((node) => { + if (getTargetModuleKind(node) !== "image") return node; + const selected = node.id === selectedNodeId; + const width = selected ? 622 : 350; + const height = selected ? 390 : 350; + if (node.width === width && node.height === height) return node; + changed = true; + return { ...node, width, height }; + }); + return changed ? next : current; + }); + }, [nodes, selectedNodeId]); const [multiSelection, setMultiSelection] = useState([]); const [groupTemplates,setGroupTemplates]=useState([]); const {data:savedGroupTemplates}=useSWR<{templates:any[]}>("/api/canvas/group-templates",fetcher); @@ -4521,6 +4580,7 @@ export function LiblibVideoCanvasReplica({ const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [lineConnectState, setLineConnectState] = useState(null); const [referenceSelectionTargetId, setReferenceSelectionTargetId] = useState(null); + const [frameAnalysisSelectionTargetId, setFrameAnalysisSelectionTargetId] = useState(null); const [markSelectionTargetId, setMarkSelectionTargetId] = useState(null); const [markAnalysisState, setMarkAnalysisState] = useState(null); @@ -4537,6 +4597,7 @@ export function LiblibVideoCanvasReplica({ const [draftTitle, setDraftTitle] = useState("未命名"); const [isTitleEditing, setIsTitleEditing] = useState(false); const [activePanel, setActivePanel] = useState(null); + const [canvasViewMode, setCanvasViewMode] = useState("workflow"); const [assetManagerWidth, setAssetManagerWidth] = useState(ASSET_MANAGER_DEFAULT_WIDTH); const [historyListDownloadPendingId, setHistoryListDownloadPendingId] = useState(null); const [historyWatermarkRulesOpen, setHistoryWatermarkRulesOpen] = useState(false); @@ -5319,11 +5380,18 @@ export function LiblibVideoCanvasReplica({ ); const addTargetModule = useCallback( - (kind: AddModuleKind, point?: { x: number; y: number }) => { + (kind: AddModuleKind, point?: { x: number; y: number }, starterMode?: string, starterModel?: string) => { const viewportCenter = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2 - 90); const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1); const center = point || viewportCenter; - const node = {...created, x: Math.round(center.x-created.width/2), y: Math.round(center.y-created.height/2)}; + const node = { + ...created, + x: Math.round(center.x-created.width/2), + y: Math.round(center.y-created.height/2), + draftValues: kind === "video" && (normalizeVideoGenerationMode(starterMode) || starterModel) + ? { ...created.draftValues, params: { ...getNodeParams(created), ...(normalizeVideoGenerationMode(starterMode) ? {mode: starterMode} : {}), ...(starterModel ? {model: starterModel} : {}) } } + : created.draftValues, + }; if (!point) { for (let attempt=0; attempt<=nodes.length; attempt++) { const overlap = nodes.find(other => node.x < other.x+other.width+40 && node.x+node.width+40 > other.x && node.y < other.y+other.height+40 && node.y+node.height+40 > other.y); @@ -5333,6 +5401,8 @@ export function LiblibVideoCanvasReplica({ setViewport(current => ({...current, x:Math.round(window.innerWidth/2-(node.x+node.width/2)*current.zoom), y:Math.round(window.innerHeight/2-90-(node.y+node.height/2)*current.zoom)})); } addNodes([node], []); + setSelectedNodeId(node.id); + setSelectedEdgeId(null); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); @@ -5340,6 +5410,37 @@ export function LiblibVideoCanvasReplica({ [addNodes, nodes, toCanvasPoint] ); + useEffect(() => { + if (!loadedProject || isProjectLoading) return; + const params = new URLSearchParams(window.location.search); + const starter = params.get("starter"); + if (!starter || !["image", "video", "audio", "script", "smart-edit"].includes(starter)) return; + if (homeStarterAppliedRef.current === canvasId) return; + if (nodes.length > 0) { + const url = new URL(window.location.href); + url.searchParams.delete("starter"); + url.searchParams.delete("starterMode"); + url.searchParams.delete("starterModel"); + router.replace(`${url.pathname}${url.search}${url.hash}`, { scroll: false }); + return; + } + homeStarterAppliedRef.current = canvasId; + addTargetModule(starter as AddModuleKind, undefined, params.get("starterMode") || undefined, params.get("starterModel") || undefined); + }, [addTargetModule, canvasId, isProjectLoading, loadedProject, nodes.length, router]); + + useEffect(() => { + if (homeStarterAppliedRef.current !== canvasId || !nodes.length || !persistence.ready || homeStarterSavingRef.current) return; + homeStarterSavingRef.current = true; + void persistence.saveAndConfirm().then(saved => { + if (!saved) return; + const url = new URL(window.location.href); + url.searchParams.delete("starter"); + url.searchParams.delete("starterMode"); + url.searchParams.delete("starterModel"); + router.replace(`${url.pathname}${url.search}${url.hash}`, { scroll: false }); + }).finally(() => { homeStarterSavingRef.current = false; }); + }, [canvasId, nodes.length, persistence.ready, persistence.saveAndConfirm, router]); + const uploadCanvasMediaFile = useCallback(async (file: File) => { const mediaType = canvasMediaType(file); if (mediaType === "image") { @@ -5956,6 +6057,39 @@ export function LiblibVideoCanvasReplica({ const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; + if (actionLabel === "拉片分镜" || actionLabel === "拉片动态" || actionLabel === "拉片音乐") { + const key = actionLabel === "拉片分镜" ? "shots" : actionLabel === "拉片动态" ? "motion" : "music"; + setNodes((current) => current.map((node) => node.id !== nodeId ? node : { + ...node, + draftValues: { ...node.draftValues, dimensions: { + shots: true, motion: true, music: true, + ...((node.draftValues?.dimensions || {}) as Record), + [key]: ((node.draftValues?.dimensions || {}) as Record)[key] === false, + } }, + })); + return; + } + if (actionLabel === "上传视频拉片") { + const existing = edges.filter((edge) => edge.target === nodeId) + .map((edge) => nodes.find((node) => node.id === edge.source)) + .find((node) => node?.inputType === "video" && (node.kind === "input" || node.kind === "media")); + pendingFrameAnalysisUploadRef.current = existing ? null : nodeId; + replaceFileNodeId.current = existing?.id || null; + if (replaceFileInputRef.current) { + replaceFileInputRef.current.accept = "video/*"; + replaceFileInputRef.current.click(); + } + return; + } + if (actionLabel === "从画布选择视频拉片") { + setFrameAnalysisSelectionTargetId(nodeId); + setReferenceSelectionTargetId(null); + setSelectedNodeId(nodeId); + setSelectedEdgeId(null); + toast.info("请选择画布中已上传或已生成的视频素材"); + return; + } + const leftOfAnchor = (width: number) => Math.round(anchor.x - width - OFFICIAL_NODE_GAP); const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2); @@ -5992,9 +6126,32 @@ export function LiblibVideoCanvasReplica({ switch (actionLabel) { case "打开智能剪辑": { - setNodes(current => current.map(node => node.id === nodeId ? {...node,width:566,height:350} : node)); + setNodes(current => current.map(node => node.id === nodeId ? {...node,width:350,height:350} : node)); setSmartEditNodeId(nodeId);setSelectedNodeId(nodeId);setCanvasCommand(null);setNodeContextMenu(null);setConnectionMenu(null);return; } + case "讲解视频": + case "批量广告": + case "口播视频": + case "素材混剪": { + const mode: SmartEditMode = actionLabel === "讲解视频" ? "explain" : actionLabel === "批量广告" ? "ads" : actionLabel === "口播视频" ? "talk" : "montage"; + setNodes(current => current.map(node => node.id === nodeId ? { + ...node, + draftValues: { + ...node.draftValues, + smartEditDraft: { + ...(node.draftValues?.smartEditDraft as Record || {}), + mode, + ...(mode === "montage" ? {resolution:"1080p",targetDuration:20} : {}), + }, + }, + } : node)); + setSelectedNodeId(nodeId); + setSelectedEdgeId(null); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + return; + } case "打开导演台": { setDirectorConsoleNodeId(nodeId); setSelectedNodeId(nodeId); @@ -6099,6 +6256,10 @@ export function LiblibVideoCanvasReplica({ return; } case "标记": { + if (!nodes.some((item) => item.inputType === "image" && getNodeReferenceMediaUrl(item))) { + toast.info("请先上传或生成图片,再选择局部元素进行标记"); + return; + } setReferenceSelectionTargetId(null); setMarkSelectionTargetId((current) => current === nodeId ? null : nodeId, @@ -6270,7 +6431,7 @@ export function LiblibVideoCanvasReplica({ toast.success(`已按「${actionLabel}」创建关联节点`); } }, - [nodes] + [addNodes, edges, nodes] ); const runSelectedImageToolAction = useCallback( @@ -7347,12 +7508,15 @@ export function LiblibVideoCanvasReplica({ const starterMode = getPresetMode(anchor); const slashScene = payload.slashScene || ""; const promptInput = withConnectedText(payload.prompt, incoming); + if (!promptInput.trim() && !slashScene.trim()) { + throw new Error("请先输入图片生成或编辑指令"); + } const slashFunctionKey = getOfficialImagePresetFunctionKey(slashScene); const functionKey = slashFunctionKey || (starterMode === CHARACTER_THREE_VIEW_FUNCTION_KEY ? CHARACTER_THREE_VIEW_FUNCTION_KEY - : "image-repaint"); + : mergedReferenceImages.length > 0 ? "image-repaint" : "image-generate"); const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey); const isWideGeneration = isWideImageGenerationFunction(functionKey); const resultWidth = isWideGeneration ? OFFICIAL_NINE_GRID_WIDTH : 420; @@ -7368,7 +7532,7 @@ export function LiblibVideoCanvasReplica({ definition, promptInput || slashScene || definition.label, ) - : composeFunctionPrompt(definition, promptInput || slashScene || "生成角色三视图"); + : composeFunctionPrompt(definition, promptInput || slashScene); const placeholderNode = isWideGeneration ? createMediaResourceNode({ @@ -7544,9 +7708,9 @@ export function LiblibVideoCanvasReplica({ const ratio = normalizeVideoRatioValue( requestedSettings?.ratio || params.ratio || paramsSettings.ratio, ); - const resolution = normalizeVideoResolutionValue( - requestedSettings?.resolution || params.resolution || paramsSettings.resolution, - ); + const requestedResolution = requestedSettings?.resolution || params.resolution || paramsSettings.resolution; + assertExecutableVideoResolution(requestedResolution); + const resolution = normalizeVideoResolutionValue(requestedResolution); const duration = normalizeVideoDurationValue( requestedSettings?.duration || params.duration || paramsSettings.duration, ); @@ -8455,13 +8619,13 @@ export function LiblibVideoCanvasReplica({ ); const requestVideoAnalysis = useCallback( - async (nodeId: string, force = false) => { + async (nodeId: string, force = false, options?: { anchorId?: string; dimensions?: Record }) => { const node = nodes.find((item) => item.id === nodeId); if (!node) { toast.error("源视频节点已移除,请重新添加视频"); return; } - const videoUrl = getNodeReferenceMediaUrl(node); + const videoUrl = getNodePlayableVideoUrl(node); const previewImageUrl = getVideoNodePreviewUrl(node); if (!videoUrl) { toast.error("当前视频暂时没有可解析的素材地址"); @@ -8470,7 +8634,8 @@ export function LiblibVideoCanvasReplica({ const existing = nodes.find( (item) => getDraftString(item, "action") === "VIDEO_STORY_ANALYSIS" && - getDraftString(item, "sourceVideoNodeId") === nodeId, + getDraftString(item, "sourceVideoNodeId") === nodeId && + (options?.anchorId ? getDraftString(item, "frameAnalysisNodeId") === options.anchorId : !getDraftString(item, "frameAnalysisNodeId")), ); if ( existing && @@ -8488,10 +8653,20 @@ export function LiblibVideoCanvasReplica({ toast.error("视频解析功能尚未启用"); return; } + const selectedDimensions = options?.dimensions; + const dimensionPrompt = selectedDimensions ? [ + selectedDimensions.shots !== false ? "分镜:识别镜头边界、时间码、景别、关键帧与画面叙事。" : "", + selectedDimensions.motion !== false ? "动态:解析主体动作、摄影机运动与节奏变化。" : "", + selectedDimensions.music !== false ? "音乐:解析背景音乐、人声和音效的时间分布。" : "", + selectedDimensions.shots === false ? "未选择分镜:仅按必要时间片段切分,景别和关键帧字段留空。" : "", + selectedDimensions.motion === false ? "未选择动态:动作和摄影机运动字段留空。" : "", + selectedDimensions.music === false ? "未选择音乐:背景音乐、人声和音效字段留空。" : "", + ].filter(Boolean).join("\n") : ""; const prompt = composeFunctionPrompt( definition, [ "请解析当前视频,输出结构化视频故事表。", + dimensionPrompt, "每个镜头包含镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度与运动、焦距与景深、光线、背景音乐、人声/音效、图像提示词、视频运动提示词、关键帧建议。无法确定的信息留空,禁止编造。", "图像与视频运动提示词优先使用英文,其他说明使用中文。", `视频标题:${node.title}`, @@ -8505,13 +8680,15 @@ export function LiblibVideoCanvasReplica({ }); storyNode.draftValues = { ...storyNode.draftValues, + frameAnalysisNodeId: options?.anchorId, generated: false, status: "loading", error: undefined, generationStatus: "pending", generationError: undefined, }; - const edge = { id: makeId("edge"), source: nodeId, target: storyNode.id }; + const outputSourceId = options?.anchorId || nodeId; + const edge = { id: makeId("edge"), source: outputSourceId, target: storyNode.id }; activeGenerationSubmissions.current.add(lockKey); setNodes((current) => current.some((item) => item.id === storyNode.id) @@ -8519,7 +8696,7 @@ export function LiblibVideoCanvasReplica({ : [...current, storyNode], ); setEdges((current) => - current.some((item) => item.source === nodeId && item.target === storyNode.id) + current.some((item) => item.source === outputSourceId && item.target === storyNode.id) ? current : [...current, edge], ); @@ -8534,6 +8711,7 @@ export function LiblibVideoCanvasReplica({ imageUrls: previewImageUrl && previewImageUrl !== videoUrl ? [previewImageUrl] : undefined, mode: "video-analysis", + analysisDimensions: selectedDimensions, nodeTitle: node.title, preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition), prompt, @@ -8647,6 +8825,7 @@ export function LiblibVideoCanvasReplica({ return; } const ratio = normalizeVideoRatioValue(settings?.ratio); + assertExecutableVideoResolution(settings?.resolution); const resolution = normalizeVideoResolutionValue(settings?.resolution); const duration = normalizeVideoDurationValue(settings?.duration); const preferredModelOverride = settings?.model || ""; @@ -8820,6 +8999,17 @@ export function LiblibVideoCanvasReplica({ setConnectionMenu(null); if (getDraftString(node,"action")==="VIDEO_HD_UPSCALE") {await generateVideoHdUpscaleNode(node.id);return;} + if (getTargetModuleKind(node) === "frame-analysis") { + const incoming = readyCanvasInputs(nodes, edges, nodeId); + if (!incoming) return; + const input = incoming.find((item) => item.inputType === "video" && !!getNodePlayableVideoUrl(item)); + if (!input) { toast.error("请先上传或连接一个已就绪的视频素材"); return; } + await requestVideoAnalysis(input.id, true, { + anchorId: nodeId, + dimensions: (node.draftValues?.dimensions || {}) as Record, + }); + return; + } if (getTargetModuleKind(node) === "smart-edit") { setSmartEditNodeId(node.id); return; } if (node.inputType === "audio" && node.draftValues?.audioParams) { const incoming = readyCanvasInputs(nodes, edges, nodeId); @@ -9554,6 +9744,23 @@ export function LiblibVideoCanvasReplica({ }); return; } + if (frameAnalysisSelectionTargetId) { + event.preventDefault(); + const targetId = frameAnalysisSelectionTargetId; + if (node.id === targetId || node.inputType !== "video" || !getNodePlayableVideoUrl(node)) return; + const connected = edges.some(edge => edge.source === node.id && edge.target === targetId); + if (!connected && !connectExistingNodes(node.id, targetId, { notify: false })) return; + setFrameAnalysisSelectionTargetId(null); + setSelectedNodeId(targetId); + setSelectedEdgeId(null); + setInspectorNodeId(null); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + toast.success("已选择视频素材"); + return; + } if (referenceSelectionTargetId) { event.preventDefault(); const mediaType = getDraftString(node, "mediaType") || node.inputType; @@ -9632,6 +9839,7 @@ export function LiblibVideoCanvasReplica({ connectExistingNodes, getCanvasPoint, lineConnectState, + frameAnalysisSelectionTargetId, markAnalysisState, markSelectionTargetId, referenceSelectionTargetId, @@ -9705,6 +9913,7 @@ export function LiblibVideoCanvasReplica({ setActivePanel(null); setLineConnectState(null); setReferenceSelectionTargetId(null); + setFrameAnalysisSelectionTargetId(null); setMarkSelectionTargetId(null); setMarkAnalysisState(null); } @@ -10264,7 +10473,7 @@ export function LiblibVideoCanvasReplica({ return (
locateCanvasNode(referenceSelectionTargetId)} /> ) : null} + {frameAnalysisSelectionTargetId ? ( + setFrameAnalysisSelectionTargetId(null)} + onReturn={() => locateCanvasNode(frameAnalysisSelectionTargetId)} + /> + ) : null} {markSelectionTargetId ? ( {if(!canGroupStoryboard)return;if(selectedGroup.draftValues?.groupMode!=="storyboard"){patchGroup({groupMode:"storyboard",shotNodeIds:storyboardMembers.sort((a,b)=>a.y-b.y||a.x-b.x).map(n=>n.id)});setNodes(current=>layoutCanvasGroup(current,selectedGroup.id,"horizontal"));}setStoryboardGroupId(selectedGroup.id);}} onCancel={async()=>{for(const task of latestGroupTasks.filter(t=>t.status==="pending"))await generationTasks.cancel(task.id);}}/>} {multiSelection.length>1&&!selectedGroup&&} - {!selectedGroup&&(multiSelection.length>0||selectedNodeId)&&
} -
我的编组工具箱 - {groupTemplates.map((template,index)=>
)} - {(savedGroupTemplates?.templates||[]).filter(t=>t.projectId!==canvasId).map((template,index)=>)} - -
- { - const selected=Array.from(event.target.files||[]),nodeId=replaceFileNodeId.current;event.target.value=""; - if(!selected.length||!nodeId)return; - const prepared=prepareCanvasMediaFiles(selected); - if(prepared.errors.length||!prepared.files[0]){toast.error(prepared.errors[0]||"请选择图片、视频或音频文件");return;} - const file=prepared.files[0],currentNode=nodes.find(node=>node.id===nodeId); - const expectedType=currentNode?.inputType; - if(expectedType&&["image","video","audio"].includes(expectedType)&&canvasMediaType(file)!==expectedType){ - toast.error(`请选择${expectedType==="image"?"图片":expectedType==="video"?"视频":"音频"}文件替换当前素材`);return; + { + const selected = Array.from(event.target.files || []); + const pendingAnchorId = pendingFrameAnalysisUploadRef.current; + const nodeId = replaceFileNodeId.current; + event.target.value = ""; + pendingFrameAnalysisUploadRef.current = null; + if (!selected.length) return; + const prepared = prepareCanvasMediaFiles(selected); + if (prepared.errors.length || !prepared.files[0]) { + toast.error(prepared.errors[0] || "请选择图片、视频或音频文件"); + return; } - uploadQueueRef.current=uploadQueueRef.current.catch(()=>{}).then(async()=>{ + const file = prepared.files[0]; + if (pendingAnchorId && canvasMediaType(file) !== "video") { + toast.error("请选择视频素材"); + return; + } + if (!pendingAnchorId && !nodeId) return; + const currentNode = nodes.find(node => node.id === nodeId); + const expectedType = currentNode?.inputType; + if (expectedType && ["image", "video", "audio"].includes(expectedType) && canvasMediaType(file) !== expectedType) { + toast.error(`请选择${expectedType === "image" ? "图片" : expectedType === "video" ? "视频" : "音频"}文件替换当前素材`); + return; + } + uploadQueueRef.current = uploadQueueRef.current.catch(() => {}).then(async () => { try { - const data=await uploadCanvasMediaFile(file); - setNodes(current=>current.map(node=>node.id===nodeId?{...node,kind:"media",inputType:data.mediaType,title:file.name.replace(/\.[^.]+$/,"")||node.title,draftValues:{...node.draftValues,mediaType:data.mediaType,mediaUrl:data.url,url:[data.url],isExample:false,generated:false,generationStatus:"idle",durationSeconds:data.duration,outputSizeLabel:data.width&&data.height?`${data.width} × ${data.height}`:undefined,uploadStatus:"complete",uploadError:undefined}}:node)); - toast.success(`${file.name} 替换完成`); - }catch(error){toast.error(error instanceof Error?error.message:"上传失败,原素材已保留");} + const data = await uploadCanvasMediaFile(file); + const mediaTitle = file.name.replace(/\.[^.]+$/, ""); + const outputSizeLabel = data.width && data.height ? `${data.width} × ${data.height}` : undefined; + if (pendingAnchorId) { + const anchor = nodes.find(node => node.id === pendingAnchorId); + if (!anchor) return; + const videoInput = createInputNode("video", Math.round(anchor.x - 158 - OFFICIAL_NODE_GAP), Math.round(anchor.y + anchor.height / 2 - 79)); + addNodes([{ + ...videoInput, + kind: "media", + title: mediaTitle || "拉片视频素材", + draftValues: { + ...videoInput.draftValues, + mediaType: "video", + mediaUrl: data.url, + url: [data.url], + isExample: false, + generated: false, + generationStatus: "idle", + durationSeconds: data.duration, + outputSizeLabel, + uploadStatus: "complete", + }, + }], [{ id: makeId("edge"), source: videoInput.id, target: pendingAnchorId }]); + } else { + setNodes(current => current.map(node => node.id === nodeId ? { + ...node, + kind: "media", + inputType: data.mediaType, + title: mediaTitle || node.title, + draftValues: { + ...node.draftValues, + mediaType: data.mediaType, + mediaUrl: data.url, + url: [data.url], + isExample: false, + generated: false, + generationStatus: "idle", + durationSeconds: data.duration, + outputSizeLabel, + uploadStatus: "complete", + uploadError: undefined, + }, + } : node)); + } + toast.success(`${file.name} ${pendingAnchorId ? "上传" : "替换"}完成`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "上传失败,原素材已保留"); + } }); - }}/> + }} /> {cancelGenerationConfirmNodeId ? ( setCancelGenerationConfirmNodeId(null)} @@ -10408,13 +10677,16 @@ export function LiblibVideoCanvasReplica({ source.inputType === "video" && !!getNodePlayableVideoUrl(source) && !["uploading", "failed"].includes(getDraftString(source, "uploadStatus")) && !["pending", "running", "queued", "failed", "cancelled", "canceled", "interrupted"].includes(getDraftString(source, "generationStatus")) && edges.some((edge) => edge.source === source.id && edge.target === node.id))} + frameAnalysisModelReady={videoCanvasFunctionConfig.enabled && getConfiguredFunction(videoCanvasFunctionConfig, "video-analysis").enabled && filterCanvasModels(canvasModelOptions, "multimodal").length > 0} + frameAnalysisAudioModelReady={canvasModelOptions.some((model) => model.providerType === "alibaba" && model.modelId === "qwen3.8-omni-flash")} key={node.id} node={node} smartEditPreviewUrl={getTargetModuleKind(node) === "smart-edit" ? getNodeReferenceMediaUrl(nodes.find(source => edges.some(edge => edge.target === node.id && edge.source === source.id) && source.inputType === "video") || node) : undefined} selected={multiSelection.length?multiSelection.includes(node.id):node.id === selectedNodeId} onContextMenu={(event) => openNodeContext(event, node)} onCancelGeneration={() => cancelGenerationNode(node.id)} - onChooseFile={()=>{replaceFileNodeId.current=node.id;if(replaceFileInputRef.current){replaceFileInputRef.current.accept=node.inputType==="audio"?"audio/*":node.inputType==="video"?"video/*":"image/*";replaceFileInputRef.current.click();}}} + onChooseFile={()=>{pendingFrameAnalysisUploadRef.current=null;replaceFileNodeId.current=node.id;if(replaceFileInputRef.current){replaceFileInputRef.current.accept=node.inputType==="audio"?"audio/*":node.inputType==="video"?"video/*":"image/*";replaceFileInputRef.current.click();}}} onClearGridSplit={() => dismissNodeGridSplit(node.id)} onClearGridSplitSelection={() => clearNodeGridSplitSelection(node.id)} onClearLocalEditRegions={() => clearNodeLocalEditRegions(node.id)} @@ -10520,18 +10792,17 @@ export function LiblibVideoCanvasReplica({ : null}
- {nodes.length === 0 && !activePanel ? ( + {nodes.length === 0 && !activePanel && canvasViewMode === "workflow" ? (
双击画布 自由生成节点
-
- {STARTER_CARDS.map((card, index) => { - const Icon = [ScanText, UserRound, ImageIcon, AudioLines][index]; - const gradients = ["linear-gradient(#173C27,#B4C490)", "linear-gradient(#B62927,#FF9292)", "linear-gradient(#274156,#B4D3E2)", "linear-gradient(#332248,#B7A1CF)"]; - return ; })}
@@ -10589,6 +10860,7 @@ export function LiblibVideoCanvasReplica({ onGridSplitCreateNodes={() => createGridSplitImageNodes(selectedPresetControlNode.id)} onGridSplitUpscale={(scaleFactor) => upscaleSelectedGridSplit(selectedPresetControlNode.id, scaleFactor)} onComposerSubmit={(payload) => submitSelectedImagePrompt(selectedPresetControlNode.id, payload)} + onModuleAction={(action) => runTargetModuleAction(selectedPresetControlNode.id, action)} onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)} onPresetSubmit={(prompt, settings) => runPresetComposerSubmit( @@ -10743,7 +11015,7 @@ export function LiblibVideoCanvasReplica({ const draft = resolveSmartEditIntent((selectedNode.draftValues?.smartEditDraft || {}) as Record); assertBeatMusic(draft); const assets = selectedTargetSourceNodes.filter(node => node.inputType === "video" && getNodeReferenceMediaUrl(node)).map(node => ({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),subtitles:[]})); - const params = smartEditRequestSchema.parse({ratio:"16:9",resolution:"1080p",targetDuration:20,...draft,mode:["auto","montage","talk","explain","ads"].includes(draft.mode)?draft.mode:"auto",semantic:true,operation:"plan",assets,preferredModelRef:videoCanvasFunctionConfig.defaultMultimodalModel}); + const params = smartEditRequestSchema.parse({ratio:"16:9",resolution:"720p",targetDuration:30,...draft,mode:["auto","montage","talk","explain","ads"].includes(draft.mode)?draft.mode:"auto",semantic:true,operation:"plan",assets,preferredModelRef:videoCanvasFunctionConfig.defaultMultimodalModel}); const requestId = await generationTasks.submit({...selectedNode,draftValues:{...selectedNode.draftValues,action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit"}},"smart-edit",params,edges.filter(edge => edge.target === selectedNode.id)); setNodes(current => current.map(node => node.id === selectedNode.id ? {...node,draftValues:{...node.draftValues,smartEditDraft:{...draft,assets,preparationRequestId:requestId}}} : node)); setSmartEditNodeId(selectedNode.id); @@ -10800,6 +11072,50 @@ export function LiblibVideoCanvasReplica({ /> ) : null} + {selectedNode && selectedTargetModuleKind === "image" && selectedTargetAnchor ? ( +
+ runTargetModuleAction(selectedNode.id, "参考")} + onStartMarkSelection={() => runTargetModuleAction(selectedNode.id, "标记")} + onOpenStyleLibrary={() => runTargetModuleAction(selectedNode.id, "风格")} + onSubmit={(payload) => void submitSelectedImagePrompt(selectedNode.id, payload)} + referenceThumbnailUrl={getNodeReferenceMediaUrl(selectedNode) || undefined} + /> +
+ ) : null} + + {selectedNode && selectedTargetModuleKind === "director" && selectedTargetAnchor ? ( + setNodes(current => current.map(node => node.id === selectedNode.id ? { + ...node, + draftValues: {...node.draftValues, ...patch}, + } : node))} + onSubmit={async (imageUrl, prompt) => { + const node = createTargetModuleNode("director", selectedNode.x + selectedNode.width + OFFICIAL_NODE_GAP, + selectedNode.y, nodes.length + 1); + node.title = "AI 识图重建布景"; + await generationTasks.submit(node, "analysis", { + mode: "director-reconstruct", prompt, imageUrls: [imageUrl], + preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel, + sourceDirectorNodeId: selectedNode.id, + }, [{id:makeId("edge"),source:selectedNode.id,target:node.id}]); + setDirectorConsoleNodeId(selectedNode.id); + }} + /> + ) : null} + {selectedNode && selectedTargetModuleKind === "video" && selectedTargetAnchor ? ( { setCanvasViewMode(mode); setActivePanel(null); setSelectedEdgeId(null); }} saveStatus={persistence.ready ? persistence.status : "unavailable"} saveMessage={persistence.message} onSave={persistence.save} + onBeforeSwitch={persistence.saveAndConfirm} + onOpenCanvas={(id) => router.push(`/canvas/${encodeURIComponent(id)}?mode=video&workspaceType=video`)} draftTitle={draftTitle} isTitleEditing={isTitleEditing} onAccount={() => toggleTopPanel("account")} @@ -11097,12 +11418,34 @@ export function LiblibVideoCanvasReplica({ onDraftTitleChange={setDraftTitle} onStartTitleEdit={() => setIsTitleEditing(true)} projectTitle={projectTitle} - routerBack={() => router.push("/")} + routerBack={() => { void persistence.saveAndConfirm().then(saved => { + if (saved) router.push("/video-canvas"); + else toast.error("画布尚未保存,请重试后返回项目首页"); + }); }} siteLogo={siteBrandSettings?.siteLogo} siteLogoDark={siteBrandSettings?.siteLogoDark} siteName={resolvedSiteName} /> + {canvasViewMode === "storyboard" ? ( + { + setCanvasViewMode("workflow"); + setSelectedNodeId(node.id); + setViewport((current) => ({ + ...current, + x: Math.round(window.innerWidth / 2 - (node.x + node.width / 2) * current.zoom), + y: Math.round(window.innerHeight / 2 - (node.y + node.height / 2) * current.zoom), + })); + }} + onReorder={(orderedIds) => setNodes((current) => current.map((node) => { + const index = orderedIds.indexOf(node.id); + return index < 0 ? node : { ...node, draftValues: { ...node.draftValues, storyboardOrder: index } }; + }))} + /> + ) : null} + setPanMode(current => !current)} @@ -11151,6 +11494,7 @@ export function LiblibVideoCanvasReplica({ setActivePanel(null)} + onSearch={() => { setActivePanel(null); setNodeSearchOpen(true); }} onGallerySelect={() => setActivePanel("assets")} onHistorySelect={() => setActivePanel("historyPicker")} onUploadFiles={addUploadedFiles} @@ -11159,6 +11503,12 @@ export function LiblibVideoCanvasReplica({ {activePanel === "workflow" ? ( + {!selectedGroup&&(multiSelection.length>0||selectedNodeId)&&} + {groupTemplates.map((template,index)=>
)} + {(savedGroupTemplates?.templates||[]).filter(t=>t.projectId!==canvasId).map((template,index)=>)} + + } workflows={capturedWorkflows} onAdd={addWorkflowPreset} onClose={() => setActivePanel(null)} @@ -11558,9 +11908,14 @@ export function LiblibVideoCanvasReplica({ } function TopBar({ + canvasId, + canvasViewMode, + onCanvasViewModeChange, saveStatus, saveMessage, onSave, + onBeforeSwitch, + onOpenCanvas, draftTitle, isTitleEditing, onAccount, @@ -11574,9 +11929,14 @@ function TopBar({ siteLogoDark, siteName, }: { + canvasId: string; + canvasViewMode: CanvasViewMode; + onCanvasViewModeChange: (mode: CanvasViewMode) => void; saveStatus: "saved" | "pending" | "saving" | "error" | "unavailable"; saveMessage?: string; onSave: () => void; + onBeforeSwitch: () => Promise; + onOpenCanvas: (id: string) => void; draftTitle: string; isTitleEditing: boolean; onAccount: () => void; @@ -11594,36 +11954,23 @@ function TopBar({ const credits = Math.max(0, Number(membershipSummary?.summary?.currentCredits ?? 0)); return ( -
-
+
+
- {isTitleEditing ? ( - onDraftTitleChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") onCommitTitle(); - }} - value={draftTitle} - /> - ) : ( - - )} + 未命名工作区 + +
+ + +
@@ -11663,6 +12010,200 @@ function TopBar({ ); } +function CanvasStoryboardView({ + nodes, + onOpenNode, + onReorder, +}: { + nodes: CloneNode[]; + onOpenNode: (node: CloneNode) => void; + onReorder: (orderedIds: string[]) => void; +}) { + const columns = [ + { key: "text", label: "文本", icon: FileText }, + { key: "image", label: "图片", icon: ImageIcon }, + { key: "video", label: "视频", icon: Video }, + ] as const; + const visibleNodes = nodes.filter((node) => !isCanvasGroup(node)); + const category = (node: CloneNode) => { + if (node.inputType === "image") return "image"; + if (node.inputType === "video") return "video"; + if (node.inputType === "text" || node.kind === "script" || getTargetModuleKind(node) === "script") return "text"; + return null; + }; + return ( +
event.stopPropagation()}> +
+ {columns.map(({ key, label, icon: Icon }) => { + const entries = visibleNodes.filter((node) => category(node) === key).sort((a, b) => { + const aOrder = Number(a.draftValues?.storyboardOrder); + const bOrder = Number(b.draftValues?.storyboardOrder); + if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) return aOrder - bOrder; + return a.x - b.x || a.y - b.y; + }); + return
+

{label}{entries.length}

+
+ {entries.length ? entries.map((node) => { + const preview = key === "text" ? "" : key === "video" ? getDraftString(node, "poster") || getDraftString(node, "previewImageUrl") : getNodeMediaUrl(node); + const videoUrl = key === "video" ? getNodePlayableVideoUrl(node) : ""; + return ; + }) :

暂无 {label}

} +
+
; + })} +
+
+ ); +} + +type CanvasListItem = { id: string; title: string; createdAt: string }; + +function CanvasSwitcher({ + canvasId, + currentTitle, + draftTitle, + isTitleEditing, + onBeforeSwitch, + onCommitTitle, + onDraftTitleChange, + onOpenCanvas, + onStartTitleEdit, +}: { + canvasId: string; + currentTitle: string; + draftTitle: string; + isTitleEditing: boolean; + onBeforeSwitch: () => Promise; + onCommitTitle: () => void; + onDraftTitleChange: (value: string) => void; + onOpenCanvas: (id: string) => void; + onStartTitleEdit: () => void; +}) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [projects, setProjects] = useState([]); + const menuRef = useRef(null); + + useEffect(() => { + setOpen(false); + }, [canvasId]); + useEffect(() => { + if (!open) return; + const closeOutside = (event: PointerEvent) => { + if (!menuRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("pointerdown", closeOutside); + return () => document.removeEventListener("pointerdown", closeOutside); + }, [open]); + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + setLoading(true); + (async () => { + try { + const collected: CanvasListItem[] = []; + for (let page = 1; page <= 200; page++) { + const response = await fetch(`/api/canvas?workspaceType=video&limit=50&page=${page}`, { + cache: "no-store", + signal: controller.signal, + }); + const result = await response.json(); + if (!response.ok || !result.success || !Array.isArray(result.data)) { + throw new Error(result.error || "读取画布列表失败"); + } + collected.push(...result.data); + if (!result.pagination?.hasMore) break; + } + if (!controller.signal.aborted) setProjects(collected); + } catch (error) { + if (!controller.signal.aborted) toast.error(error instanceof Error ? error.message : "读取画布列表失败"); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + })(); + return () => controller.abort(); + }, [open]); + + const ordered = [...projects].sort((left, right) => + new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime() + ); + const label = (project: CanvasListItem, index: number) => { + const title = project.id === canvasId ? currentTitle : project.title; + return !title || /^未命名(?:(?:视频)?画布)?$/.test(title) ? `画布 ${index + 1}` : title; + }; + const currentIndex = ordered.findIndex((project) => project.id === canvasId); + const currentLabel = currentIndex >= 0 ? label(ordered[currentIndex], currentIndex) : + currentTitle && !/^未命名(?:(?:视频)?画布)?$/.test(currentTitle) ? currentTitle : "画布 1"; + const switchTo = async (id: string) => { + if (id === canvasId || busy) { setOpen(false); return; } + setBusy(true); + try { + if (!await onBeforeSwitch()) { toast.error("当前画布尚未保存,请先解决保存错误"); return; } + setOpen(false); + onOpenCanvas(id); + } finally { setBusy(false); } + }; + const createCanvas = async () => { + if (busy || loading) return; + setBusy(true); + try { + if (!await onBeforeSwitch()) { toast.error("当前画布尚未保存,请先解决保存错误"); return; } + const highest = ordered.reduce((value, project) => { + const match = /^画布\s*(\d+)$/.exec(project.title || ""); + return match ? Math.max(value, Number(match[1])) : value; + }, ordered.length); + const response = await fetch("/api/canvas", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: `画布 ${highest + 1}`, workspaceType: "video" }), + }); + const result = await response.json(); + if (!response.ok || !result.success || !result.data?.id) { + throw new Error(result.error || "新建画布失败"); + } + setOpen(false); + onOpenCanvas(result.data.id); + } catch (error) { + toast.error(error instanceof Error ? error.message : "新建画布失败"); + } finally { setBusy(false); } + }; + return ( +
+ {isTitleEditing ? onDraftTitleChange(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") onCommitTitle(); }} value={draftTitle} /> : } + {open ?
+
画布
+
+ {loading ?
加载中…
: ordered.map((project, index) =>
{project.id === canvasId ? : null}
)} +
+
: null} +
+ ); +} + function formatCreditValue(value: number) { if (!Number.isFinite(value)) return "0"; return Math.round(value).toLocaleString("zh-CN"); @@ -13254,8 +13795,8 @@ function VideoSubtitleErasePendingCanvasNode({ return (
-
- +
+ {node.title}
-
+
{progressLabel}