diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 206a1a972..cce12b360 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -15,10 +15,8 @@ import { Music2, Plus, Play, - Save, Scissors, Settings2, - Share2, Sparkles, Trash2, Upload, @@ -238,6 +236,41 @@ type SiteBrandSettings = { siteLogoDark?: string | null; }; +type MembershipSummaryResponse = { + summary?: { + currentCredits?: number | null; + }; +}; + +type GridCellVisual = { + fit?: "cover" | "contain"; + objectPosition?: string; + scale?: number; + translateX?: number; + translateY?: number; +}; + +type GridCropRect = { + height: number; + width: number; + x: number; + y: number; +}; + +type GridSplitSpec = { + columns: number; + detected: boolean; + rects: GridCropRect[]; + rows: number; +}; + +type AngleEditorSettings = { + horizontalAngle: number; + promptEnabled: boolean; + verticalAngle: number; + zoom: number; +}; + const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[]; const capturedCounts = (capturedCanvasModules as any).counts || {}; const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as WorkflowPreset[]; @@ -323,7 +356,7 @@ const STARTER_CARDS = [ const TOOLBAR_ITEMS: Array<{ id: Exclude; label: string; icon: LucideIcon }> = [ { id: "add", label: "添加模块", icon: Plus }, - { id: "workflow", label: "工作流", icon: GitBranch }, + { id: "workflow", label: "工具箱", icon: GitBranch }, { id: "assets", label: "素材", icon: Layers }, { id: "history", label: "历史", icon: Clock3 }, { id: "help", label: "帮助", icon: HelpCircle }, @@ -338,10 +371,23 @@ const OFFICIAL_NODE_GAP = 100; const OFFICIAL_TEXT_RESOURCE_HEIGHT = 232; const OFFICIAL_TEXT_RESOURCE_WIDTH = 350; const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7; -const OFFICIAL_COMPOSER_SCALE = 0.6; +const OFFICIAL_COMPOSER_SCALE = 1; const PROMPT_COMPOSER_BASE_WIDTH = 895; const PROMPT_COMPOSER_BASE_HEIGHT = 286; const PRESET_COMPOSER_BASE_HEIGHT = 238; +const MULTI_VIEW_NINE_GRID_PROMPT = + "以选中图片为角色/主体参考,生成多视角 3x3 九宫格分镜图。保持主体一致,九个镜头包含远景、全身、侧身、背面、半身、近景、局部细节等视角,使用清晰黑色分割线分隔每一格,白色或干净背景。"; +const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [ + { fit: "contain", objectPosition: "50% 50%", scale: 0.62 }, + { fit: "contain", objectPosition: "58% 50%", scale: 0.74, translateX: -10 }, + { fit: "contain", objectPosition: "68% 50%", scale: 0.86, translateX: -18 }, + { objectPosition: "34% 24%", scale: 1.4, translateX: 8, translateY: 8 }, + { objectPosition: "52% 28%", scale: 1.55, translateY: 5 }, + { objectPosition: "66% 26%", scale: 1.72, translateX: -8, translateY: 2 }, + { objectPosition: "20% 72%", scale: 2.08, translateX: 22, translateY: -12 }, + { objectPosition: "52% 68%", scale: 1.24, translateY: -4 }, + { objectPosition: "70% 64%", scale: 1.46, translateX: -14, translateY: -6 }, +]; const TARGET_AVATAR = "https://liblibai-online.liblib.cloud/web/1656e2f1b3c74f1ba823b610f1b2cbdf/0c3eb5e2-1dc0-405c-a823-07b2570237f8.jpg?x-oss-process=image/resize,w_100,m_lfit/format,webp"; @@ -950,6 +996,73 @@ function createMediaResourceNode({ }; } +function createNineGridCompositeNode({ + anchor, + columns, + option, + rows, + sourceUrl, + x, + y, +}: { + anchor: CloneNode; + columns: number; + option?: string; + rows: number; + sourceUrl: string; + x: number; + y: number; +}): CloneNode { + const cellCount = rows * columns; + const title = + cellCount === 9 + ? "多机位九宫格" + : option?.trim() || `${cellCount}宫格`; + const gridPrompt = + cellCount === 9 && (!option || /多|9|九/.test(option)) + ? MULTI_VIEW_NINE_GRID_PROMPT + : `基于选中图片生成 ${columns}x${rows} 宫格分镜图,使用黑色分割线分隔每一格。`; + + return { + id: makeId("image"), + kind: "media", + title, + subtitle: "生成结果", + inputType: "image", + x, + y, + width: 560, + height: 347, + accent: "#8790ff", + draftValues: { + action: "IMAGE_GENERATE_NINE_GRID", + generated: true, + gridColumns: columns, + gridPrompt, + gridRows: rows, + mediaType: "image", + nineGridCells: Array.from({ length: cellCount }).map((_, index) => getGridCellVisual(index)), + nineGridComposite: true, + nodeType: "image", + outputSizeLabel: cellCount === 9 ? "2752 × 1536" : "", + params: { + columns, + mode: "grid", + option: option || "", + prompt: gridPrompt, + rows, + sourceNodeId: anchor.id, + sourceUrl, + }, + prompt: gridPrompt, + sourceNodeId: anchor.id, + sourceUrl, + toolbarOption: option || "", + url: [], + }, + }; +} + function createTextResourceNode({ content, height = OFFICIAL_TEXT_RESOURCE_HEIGHT, @@ -1002,6 +1115,198 @@ function preventNativeImageDrag(event: ReactDragEvent) { event.preventDefault(); } +function normalizeGridOption(option?: string) { + const text = String(option || "").trim(); + const tileCount = + /25\s*宫格|5\s*[x×]\s*5|25/.test(text) + ? 25 + : /16\s*宫格|4\s*[x×]\s*4|16/.test(text) + ? 16 + : /4\s*宫格|2\s*[x×]\s*2|四/.test(text) + ? 4 + : 9; + const columns = tileCount === 4 ? 2 : tileCount === 16 ? 4 : tileCount === 25 ? 5 : 3; + const rows = Math.ceil(tileCount / columns); + return { columns, rows, tileCount }; +} + +function getGridCellVisual(index: number): GridCellVisual { + return NINE_GRID_CELL_VISUALS[index % NINE_GRID_CELL_VISUALS.length]; +} + +function getNodeSourceMediaUrl(node: CloneNode | null | undefined) { + return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node); +} + +function createEqualGridRects(rows: number, columns: number): GridCropRect[] { + return Array.from({ length: rows * columns }).map((_, index) => { + const column = index % columns; + const row = Math.floor(index / columns); + return { + height: 1 / rows, + width: 1 / columns, + x: column / columns, + y: row / rows, + }; + }); +} + +function buildGridRectsFromLines({ + columns, + height, + horizontalLines, + rows, + verticalLines, + width, +}: { + columns: number; + height: number; + horizontalLines: number[]; + rows: number; + verticalLines: number[]; + width: number; +}): GridCropRect[] { + const lineMargin = Math.max(1, Math.round(Math.min(width, height) * 0.0015)); + const xBounds = [0, ...verticalLines, width]; + const yBounds = [0, ...horizontalLines, height]; + const rects: GridCropRect[] = []; + + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const left = column === 0 ? xBounds[column] : xBounds[column] + lineMargin; + const right = column === columns - 1 ? xBounds[column + 1] : xBounds[column + 1] - lineMargin; + const top = row === 0 ? yBounds[row] : yBounds[row] + lineMargin; + const bottom = row === rows - 1 ? yBounds[row + 1] : yBounds[row + 1] - lineMargin; + rects.push({ + height: Math.max((bottom - top) / height, 0.02), + width: Math.max((right - left) / width, 0.02), + x: Math.max(left / width, 0), + y: Math.max(top / height, 0), + }); + } + } + + return rects; +} + +function getLineScore(data: Uint8ClampedArray, width: number, height: number, axis: "x" | "y", index: number) { + const limit = axis === "x" ? height : width; + const step = Math.max(1, Math.floor(limit / 260)); + let score = 0; + let samples = 0; + + for (let point = 0; point < limit; point += step) { + const x = axis === "x" ? index : point; + const y = axis === "x" ? point : index; + const offset = (y * width + x) * 4; + const alpha = data[offset + 3] / 255; + if (alpha < 0.1) continue; + const luminance = data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114; + score += luminance < 96 ? ((96 - luminance) / 96) * alpha : 0; + samples += 1; + } + + return samples > 0 ? score / samples : 0; +} + +function findGridDividerLines( + data: Uint8ClampedArray, + width: number, + height: number, + count: number, + axis: "x" | "y" +) { + const size = axis === "x" ? width : height; + const otherSize = axis === "x" ? height : width; + const lines: number[] = []; + let detectedCount = 0; + + for (let divider = 1; divider < count; divider += 1) { + const expected = (divider / count) * size; + const searchRadius = Math.max(8, Math.round(size * 0.08)); + const from = Math.max(1, Math.round(expected - searchRadius)); + const to = Math.min(size - 2, Math.round(expected + searchRadius)); + let bestIndex = Math.round(expected); + let bestScore = 0; + + for (let index = from; index <= to; index += 1) { + const score = + getLineScore(data, width, height, axis, index - 1) * 0.25 + + getLineScore(data, width, height, axis, index) * 0.5 + + getLineScore(data, width, height, axis, index + 1) * 0.25; + if (score > bestScore) { + bestScore = score; + bestIndex = index; + } + } + + const minimumScore = otherSize > 0 ? 0.18 : 0.22; + if (bestScore >= minimumScore) { + detectedCount += 1; + lines.push(bestIndex); + } else { + lines.push(Math.round(expected)); + } + } + + return { + detected: detectedCount === Math.max(0, count - 1), + lines, + }; +} + +async function detectGridSplitFromImage(url: string, rows: number, columns: number): Promise { + if (!url || typeof window === "undefined") return null; + + try { + const image = await new Promise((resolve, reject) => { + const nextImage = new Image(); + if (!url.startsWith("blob:") && !url.startsWith("data:")) { + nextImage.crossOrigin = "anonymous"; + nextImage.referrerPolicy = "no-referrer"; + } + nextImage.onload = () => resolve(nextImage); + nextImage.onerror = () => reject(new Error("image-load-failed")); + nextImage.src = url; + }); + const naturalWidth = image.naturalWidth || image.width; + const naturalHeight = image.naturalHeight || image.height; + if (!naturalWidth || !naturalHeight) return null; + + const maxSide = 980; + const scale = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight)); + const width = Math.max(1, Math.round(naturalWidth * scale)); + const height = Math.max(1, Math.round(naturalHeight * scale)); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + + context.drawImage(image, 0, 0, width, height); + const imageData = context.getImageData(0, 0, width, height); + const verticalResult = findGridDividerLines(imageData.data, width, height, columns, "x"); + const horizontalResult = findGridDividerLines(imageData.data, width, height, rows, "y"); + const rects = buildGridRectsFromLines({ + columns, + height, + horizontalLines: horizontalResult.lines, + rows, + verticalLines: verticalResult.lines, + width, + }); + + return { + columns, + detected: verticalResult.detected && horizontalResult.detected, + rects, + rows, + }; + } catch (_error) { + return null; + } +} + function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene { const value = getDraftString(node, "targetPreset"); if ( @@ -1016,7 +1321,7 @@ function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene { } function isOfficialImageControlNode(node: CloneNode | null | undefined): node is CloneNode { - return !!node && node.kind === "media" && node.inputType === "image" && !!getNodeMediaUrl(node); + return !!node && (node.kind === "media" || node.kind === "script") && !!getPresetMode(node); } function getTargetModuleKind(node: CloneNode): AddModuleKind | null { @@ -1317,29 +1622,29 @@ function createStarterWorkflow( if (starterTitle === "音频生视频") { const preset = "audio-to-video"; - const leftX = Math.round(center.x - 510); - const leftY = Math.round(center.y - 320); + const leftX = Math.round(center.x - 900); + const leftY = Math.round(center.y - 370); const audio = createPresetMediaNode({ action: "AUDIO_RESOURCE", - height: 158, + height: 190, mediaType: "audio", mediaUrl: AUDIO_SOURCE_URL, preset, title: "音频", - width: 430, + width: 720, x: leftX, y: leftY, }); const image = createPresetMediaNode({ action: "IMAGE_RESOURCE", - height: 252, + height: 400, mediaType: "image", mediaUrl: AUDIO_REFERENCE_IMAGE_URL, preset, title: "图片", - width: 430, + width: 720, x: leftX, - y: leftY + 190, + y: leftY + 300, }); const right = createPresetMediaNode({ action: "VIDEO_GENERATE", @@ -1349,9 +1654,9 @@ function createStarterWorkflow( preset, prompt: AUDIO_TO_VIDEO_PROMPT, title: "视频", - width: 560, - x: Math.round(center.x + 100), - y: Math.round(center.y - 320), + width: 760, + x: Math.round(center.x + 90), + y: Math.round(center.y - 300), }); return { nodes: [audio, image, right], edges: makePresetEdges([audio, image], right), selectedId: right.id }; } @@ -1380,6 +1685,9 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const [nodeContextMenu, setNodeContextMenu] = useState(null); const [connectionMenu, setConnectionMenu] = useState(null); const [previewNode, setPreviewNode] = useState(null); + const [angleEditorNodeId, setAngleEditorNodeId] = useState(null); + const [dockOverviewOpen, setDockOverviewOpen] = useState(false); + const [snapEnabled, setSnapEnabled] = useState(true); const selectedNode = useMemo( () => nodes.find((node) => node.id === selectedNodeId) || null, @@ -1396,6 +1704,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib [connectionMenu?.nodeId, nodes] ); + const angleEditorNode = useMemo( + () => nodes.find((node) => node.id === angleEditorNodeId) || null, + [angleEditorNodeId, nodes] + ); + useEffect(() => { let active = true; @@ -1473,11 +1786,50 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const addWorkflowPreset = useCallback( (workflow: WorkflowPreset, index = 0) => { - const node = createWorkflowNode(workflow, 440 + index * 24, 350 + index * 18); - addNodes([node], []); + const title = String(workflow.name || workflow.title || "工具箱预设"); + const baseX = 360 + index * 36; + const baseY = 300 + index * 24; + const reference = createMediaResourceNode({ + action: "IMAGE_RESOURCE", + height: 280, + mediaType: "image", + mediaUrl: workflow.cover || CHARACTER_IMAGE_URL, + title: "参考图", + width: 420, + x: baseX, + y: baseY, + }); + const result = createMediaResourceNode({ + action: "VIDEO_GENERATE", + height: 315, + mediaType: "video", + mediaUrl: "", + prompt: workflow.description || `${title} 预设工作流`, + title, + width: 560, + x: baseX + 520, + y: baseY - 18, + }); + const presetNode: CloneNode = { + ...result, + subtitle: "工具箱工作流", + draftValues: { + ...(result.draftValues || {}), + generated: true, + params: { + prompt: workflow.description || `${title} 预设工作流`, + presetId: workflow.uuid || workflow.id || "", + presetName: title, + sourceNodeId: reference.id, + }, + sourceNodeId: reference.id, + workflow, + }, + }; + addNodes([reference, presetNode], [{ id: makeId("edge"), source: reference.id, target: presetNode.id }]); setActivePanel(null); setConnectionMenu(null); - toast.success("已添加工作流"); + toast.success(`已添加「${title}」工具箱工作流`); }, [addNodes] ); @@ -1761,8 +2113,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib kind: "media", title: "音频", subtitle: "音频资源", - width: 430, - height: 170, + width: 720, + height: 190, inputType: "audio", }; draftUpdates = { @@ -1773,15 +2125,20 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib nodeType: "audio", url: [AUDIO_SOURCE_URL], }; - const video = makeGenerated("video", "视频", rightX, { params: { prompt: AUDIO_TO_VIDEO_PROMPT } }); + const video = { + ...makeGenerated("video", "视频", Math.round(anchor.x + 720 + OFFICIAL_NODE_GAP), { params: { prompt: AUDIO_TO_VIDEO_PROMPT } }), + width: 760, + height: 430, + y: Math.round(anchor.y + 70), + }; const image = createMediaResourceNode({ mediaType: "image", mediaUrl: AUDIO_REFERENCE_IMAGE_URL, title: "图片", - width: 430, - height: 252, + width: 720, + height: 400, x: anchor.x, - y: Math.round(anchor.y + 170 + OFFICIAL_NODE_GAP), + y: Math.round(anchor.y + 300), }); nextNodes.push(video, image); nextEdges.push( @@ -1920,10 +2277,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib ); const runSelectedImageToolAction = useCallback( - (nodeId: string, action: ImageToolbarAction, option?: string) => { + async (nodeId: string, action: ImageToolbarAction, option?: string) => { const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; - const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL; + const sourceUrl = getNodeSourceMediaUrl(anchor) || CHARACTER_IMAGE_URL; if (action === "download") { if (!sourceUrl) { @@ -1986,55 +2343,118 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib return; } + if (action === "orbit") { + setSelectedNodeId(anchor.id); + setAngleEditorNodeId(anchor.id); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + return; + } + const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2); const nextNodes: CloneNode[] = []; const nextEdges: CloneEdge[] = []; if (action === "crop") { - const parsed = Number.parseInt(option || "9", 10); - const tileCount = Number.isFinite(parsed) && parsed > 0 ? parsed : 9; - const columns = Math.ceil(Math.sqrt(tileCount)); - const tileSize = 180; + const { columns, rows, tileCount } = normalizeGridOption(option); + const tileSize = tileCount === 25 ? 142 : tileCount === 16 ? 156 : 180; const gap = 26; - const rows = Math.ceil(tileCount / columns); const startY = Math.round(anchor.y + anchor.height / 2 - (rows * tileSize + (rows - 1) * gap) / 2); + const isSyntheticGrid = Boolean(anchor.draftValues?.nineGridComposite); + const anchorMediaUrl = getNodeMediaUrl(anchor); + const detectedSplit = + !isSyntheticGrid && anchorMediaUrl + ? await detectGridSplitFromImage(anchorMediaUrl, rows, columns) + : null; + const rects = + detectedSplit?.rects && detectedSplit.rects.length >= tileCount + ? detectedSplit.rects + : createEqualGridRects(rows, columns); + const sourceVisuals = Array.isArray(anchor.draftValues?.nineGridCells) + ? (anchor.draftValues?.nineGridCells as GridCellVisual[]) + : []; Array.from({ length: tileCount }).forEach((_, index) => { const tile = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: tileSize, mediaType: "image", - mediaUrl: sourceUrl, + mediaUrl: isSyntheticGrid ? sourceUrl : anchorMediaUrl || sourceUrl, prompt: option || "宫格切分", - title: `切分 ${index + 1}`, + title: `镜头 ${String(index + 1).padStart(2, "0")}`, width: tileSize, x: rightX + (index % columns) * (tileSize + gap), y: startY + Math.floor(index / columns) * (tileSize + gap), }); - nextNodes.push(tile); + nextNodes.push({ + ...tile, + subtitle: "宫格切片", + draftValues: { + ...(tile.draftValues || {}), + cropTile: { + columns, + detectedDivider: Boolean(detectedSplit?.detected), + index, + rect: rects[index], + rows, + sourceCompositeNodeId: anchor.id, + sourceMode: isSyntheticGrid ? "synthetic-grid" : "detected-lines", + }, + gridCellVisual: isSyntheticGrid ? sourceVisuals[index] || getGridCellVisual(index) : undefined, + sourceNodeId: anchor.id, + sourceUrl, + }, + }); nextEdges.push({ id: makeId("edge"), source: anchor.id, target: tile.id }); }); setNodes((current) => [...current, ...nextNodes]); setEdges((current) => [...current, ...nextEdges]); - setSelectedNodeId(anchor.id); + setSelectedNodeId(nextNodes[0]?.id || anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); - toast.success(`已创建「${option || "宫格切分"}」结果节点`); + toast.success( + detectedSplit?.detected + ? `已识别黑色分割线并切分为 ${tileCount} 张图片` + : `已按 ${columns}x${rows} 宫格切分为 ${tileCount} 张图片` + ); return; } - const labels: Record, string> = { + const labels: Record, string> = { grid: "九宫格", hd: "高清", lighting: "打光", - orbit: "多角度", panorama: "720 全景", }; const title = labels[action]; + if (action === "grid") { + const { columns, rows } = normalizeGridOption(option || "9宫格"); + const gridNode = createNineGridCompositeNode({ + anchor, + columns, + option, + rows, + sourceUrl, + x: rightX, + y: centerY(347), + }); + setNodes((current) => [...current, gridNode]); + setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: gridNode.id }]); + setSelectedNodeId(gridNode.id); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + toast.success("已按预设提示词生成多机位九宫格结果图"); + return; + } + const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1); const prompt = option?.trim() || title; const resultNode: CloneNode = { @@ -2303,10 +2723,13 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib if (drag.kind === "node") { const dx = (event.clientX - drag.startX) / viewport.zoom; const dy = (event.clientY - drag.startY) / viewport.zoom; + const gridSize = snapEnabled ? 8 : 1; + const nextX = Math.round((drag.nodeX + dx) / gridSize) * gridSize; + const nextY = Math.round((drag.nodeY + dy) / gridSize) * gridSize; setNodes((current) => current.map((node) => node.id === drag.id - ? { ...node, x: Math.round(drag.nodeX + dx), y: Math.round(drag.nodeY + dy) } + ? { ...node, x: nextX, y: nextY } : node ) ); @@ -2318,7 +2741,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib y: Math.round(drag.viewportY + event.clientY - drag.startY), })); }, - [viewport.zoom] + [snapEnabled, viewport.zoom] ); const onPointerUp = useCallback(() => { @@ -2346,6 +2769,40 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib }); }, []); + const fitNodesToView = useCallback( + (targetNodes: CloneNode[]) => { + if (targetNodes.length === 0) { + setViewport(DEFAULT_VIEWPORT); + return; + } + const canvasWidth = canvasRef.current?.clientWidth || window.innerWidth; + const canvasHeight = canvasRef.current?.clientHeight || window.innerHeight; + const minX = Math.min(...targetNodes.map((node) => node.x)); + const minY = Math.min(...targetNodes.map((node) => node.y)); + const maxX = Math.max(...targetNodes.map((node) => node.x + node.width)); + const maxY = Math.max(...targetNodes.map((node) => node.y + node.height)); + const boundsWidth = Math.max(1, maxX - minX); + const boundsHeight = Math.max(1, maxY - minY); + const padding = targetNodes.length === 1 ? 280 : 220; + const nextZoom = Math.min( + 1.35, + Math.max(0.3, Math.min((canvasWidth - padding) / boundsWidth, (canvasHeight - padding) / boundsHeight)) + ); + setViewport({ + x: Math.round((canvasWidth - boundsWidth * nextZoom) / 2 - minX * nextZoom), + y: Math.round((canvasHeight - boundsHeight * nextZoom) / 2 - minY * nextZoom), + zoom: Number(nextZoom.toFixed(2)), + }); + }, + [] + ); + + const focusDockTarget = useCallback(() => { + const target = selectedNode ? [selectedNode] : nodes; + fitNodesToView(target); + setDockOverviewOpen(false); + }, [fitNodesToView, nodes, selectedNode]); + const onCanvasWheel = useCallback( (event: ReactWheelEvent) => { const target = event.target as HTMLElement; @@ -2520,16 +2977,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib toggleTopPanel("account")} - onBell={() => toggleTopPanel("notifications")} onCommitTitle={commitTitle} onDraftTitleChange={setDraftTitle} - onMember={() => toggleTopPanel("member")} - onSave={saveCanvas} - onShare={() => toggleTopPanel("share")} - onSkills={() => toggleTopPanel("skills")} onStartTitleEdit={() => setIsTitleEditing(true)} projectTitle={projectTitle} routerBack={() => router.push("/")} @@ -2550,8 +3001,14 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib setViewport(DEFAULT_VIEWPORT)} + onCenter={focusDockTarget} + onToggleOverview={() => setDockOverviewOpen((value) => !value)} + onToggleSnap={() => setSnapEnabled((value) => !value)} onZoomIn={() => zoomBy(0.1)} onZoomOut={() => zoomBy(-0.1)} /> @@ -2679,6 +3136,37 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib ) : null} + {angleEditorNode ? ( + { + setNodes((current) => + current.map((node) => + node.id === angleEditorNode.id + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + angleSettings: settings, + params: { + ...((node.draftValues?.params as Record | undefined) || {}), + horizontal_angle: settings.horizontalAngle, + prompt: settings.promptEnabled ? "按自定义多角度参数生成视角变化" : "", + vertical_angle: settings.verticalAngle, + zoom: settings.zoom, + }, + }, + } + : node + ) + ); + setAngleEditorNodeId(null); + toast.success("已应用多角度参数,生成接口待接入。"); + }} + onClose={() => setAngleEditorNodeId(null)} + /> + ) : null} + {isBusy ? (
@@ -2693,16 +3181,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib function TopBar({ draftTitle, - isSaving, isTitleEditing, onAccount, - onBell, onCommitTitle, onDraftTitleChange, - onMember, - onSave, - onShare, - onSkills, onStartTitleEdit, projectTitle, routerBack, @@ -2711,16 +3193,10 @@ function TopBar({ siteName, }: { draftTitle: string; - isSaving: boolean; isTitleEditing: boolean; onAccount: () => void; - onBell: () => void; onCommitTitle: () => void; onDraftTitleChange: (value: string) => void; - onMember: () => void; - onSave: () => void; - onShare: () => void; - onSkills: () => void; onStartTitleEdit: () => void; projectTitle: string; routerBack: () => void; @@ -2728,6 +3204,9 @@ function TopBar({ siteLogoDark?: string | null; siteName?: string | null; }) { + const { data: membershipSummary } = useSWR("/api/user/membership-summary", fetcher); + const credits = Math.max(0, Number(membershipSummary?.summary?.currentCredits ?? 0)); + return (
@@ -2762,51 +3241,28 @@ function TopBar({
- LibTV Skills - - {isSaving ? : } - - - - - - - - - - - 会员超市 - - +
); } -function TopButton({ - children, - className, - onClick, - title, -}: { - children: React.ReactNode; - className?: string; - onClick: () => void; - title?: string; -}) { - return ( - - ); +function formatCreditValue(value: number) { + if (!Number.isFinite(value)) return "0"; + return Math.round(value).toLocaleString("zh-CN"); } function LeftToolbar({ @@ -2853,13 +3309,25 @@ function LeftToolbar({ function ZoomDock({ compact = false, + nodes = [], onCenter, + onToggleOverview, + onToggleSnap, + overviewOpen = false, + selectedNodeId, + snapEnabled = true, onZoomIn, onZoomOut, zoom, }: { compact?: boolean; + nodes?: CloneNode[]; onCenter: () => void; + onToggleOverview?: () => void; + onToggleSnap?: () => void; + overviewOpen?: boolean; + selectedNodeId?: string | null; + snapEnabled?: boolean; onZoomIn: () => void; onZoomOut: () => void; zoom: number; @@ -2887,28 +3355,115 @@ function ZoomDock({ } return ( -
+ {overviewOpen ? ( + + ) : null} +
+ + + + + + + + + +
+ + + +
+
+ ); +} + +function ZoomDockButton({ + active, + children, + label, + onClick, + selectedOutline, +}: { + active?: boolean; + children: ReactNode; + label: string; + onClick?: () => void; + selectedOutline?: boolean; +}) { + return ( + - - - - - + {children} + + ); +} + +function CanvasOverviewPopover({ nodes, selectedNodeId }: { nodes: CloneNode[]; selectedNodeId: string | null }) { + const bounds = useMemo(() => { + if (nodes.length === 0) return null; + const minX = Math.min(...nodes.map((node) => node.x)); + const minY = Math.min(...nodes.map((node) => node.y)); + const maxX = Math.max(...nodes.map((node) => node.x + node.width)); + const maxY = Math.max(...nodes.map((node) => node.y + node.height)); + return { minX, minY, maxX, maxY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }; + }, [nodes]); + const scale = bounds ? Math.min(188 / bounds.width, 112 / bounds.height) : 1; + + return ( +
+
+ 画布总览 + {nodes.length} 个节点 +
+
+
+ {bounds ? ( + nodes.map((node) => ( + + )) + ) : ( +
暂无节点
+ )} +
); } @@ -3018,29 +3573,6 @@ function LightningGlyph() { ); } -function MemberDealPill({ onAccount, onMember }: { onAccount: () => void; onMember: () => void }) { - return ( -
- - - -
- ); -} - function AssetGlyph() { return (
- - +
); } @@ -3227,7 +3743,10 @@ function CharacterPresetToolbar({ } > setToolbarMenu((current) => current === "panorama" ? null : "panorama")} /> - setToolbarMenu((current) => current === "orbit" ? null : "orbit")} /> + { + setToolbarMenu(null); + onToolAction("orbit"); + }} /> setToolbarMenu((current) => current === "lighting" ? null : "lighting")} /> setToolbarMenu((current) => current === "grid" ? null : "grid")} /> setToolbarMenu((current) => current === "hd" ? null : "hd")} /> @@ -3260,6 +3779,256 @@ function CharacterPresetToolbar({ ); } +function AudioToVideoComposerPanel({ anchor }: { anchor?: PresetControlAnchor }) { + return ( +
+
+ +
+ {["文生视频", "全能参考", "图生视频", "首尾帧", "图片参考"].map((tab, index) => ( + + ))} +
+
+ {[ + ["标记", "◎"], + ["运镜", "▣"], + ["角色库", "♙"], + ].map(([label, icon]) => ( + + ))} +
+ 图片参考 + 1 +
+
+ + 2 +
+
+