feat(canvas): 新增图片节点打光工具

新增图片节点打光功能,支持拖拽球体调节光源方位、选择预设打光效果,可调整亮度、光色和轮廓光,内置8种打光预设并附带静态资源。实现了基于参考图的AI打光生成,沿用原有模型配置,面板适配画布明暗主题,支持复制提示词与自定义提示词模板,窄屏下自动收进图片工具菜单。
This commit is contained in:
bensharp
2026-09-09 16:56:00 +08:00
parent bacd502f5c
commit 756a1ba2b1
17 changed files with 761 additions and 5 deletions

View File

@@ -49,6 +49,7 @@ import { CanvasTimelineDialog } from "@/components/canvas/canvas-timeline-dialog
import { syncNodeSubtitlesToTimeline } from "@/lib/timeline/timeline-build";
import type { TimelineDirectMedia } from "@/types/timeline";
import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
import { CanvasNodeLightingPanel } from "@/components/canvas/canvas-node-lighting-dialog";
import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal";
@@ -844,6 +845,7 @@ function InfiniteCanvasPage() {
const {
angleNodeId,
lightingNodeId,
emotionNodeId,
annotationNodeId,
createImageReversePromptNodes,
@@ -857,6 +859,7 @@ function InfiniteCanvasPage() {
extractingVideoFramesNodeId,
frameDialogNodeId,
generateAngleNode,
generateLightingNode,
generateEmotionNode,
handleSegmentConfirm,
maskEditImageNode,
@@ -871,6 +874,7 @@ function InfiniteCanvasPage() {
setFrameDialogNodeId,
setSegmentDialogNodeId,
setAngleNodeId,
setLightingNodeId,
setEmotionNodeId,
setAnnotationNodeId,
setCropNodeId,
@@ -923,6 +927,7 @@ function InfiniteCanvasPage() {
setSplitNodeId(clearDeletedId);
setUpscaleNodeId(clearDeletedId);
setAngleNodeId(clearDeletedId);
setLightingNodeId(clearDeletedId);
setEmotionNodeId(clearDeletedId);
setSuperResolveNodeId(clearDeletedId);
setPreviewNodeId(clearDeletedId);
@@ -940,7 +945,7 @@ function InfiniteCanvasPage() {
}
cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions });
},
[chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setArtCritiqueNodeId, setCropNodeId, setEmotionNodeId, setFrameDialogNodeId, setMaskEditNodeId, setSegmentDialogNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
[chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setArtCritiqueNodeId, setCropNodeId, setEmotionNodeId, setFrameDialogNodeId, setLightingNodeId, setMaskEditNodeId, setSegmentDialogNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
);
const {
@@ -1279,6 +1284,7 @@ function InfiniteCanvasPage() {
activeScriptNode,
activeStylePresetId,
angleNode,
lightingNode,
emotionNode,
annotationNode,
batchChildCountById,
@@ -1329,6 +1335,7 @@ function InfiniteCanvasPage() {
upscaleNodeId,
superResolveNodeId,
angleNodeId,
lightingNodeId,
emotionNodeId,
previewNodeId,
contextMenu,
@@ -1581,6 +1588,7 @@ function InfiniteCanvasPage() {
setMaskEditNodeId(null);
setAnnotationNodeId(null);
setAngleNodeId(null);
setLightingNodeId(null);
setEmotionNodeId(null);
setPreviewNodeId(null);
setRunningNodeId(null);
@@ -2507,6 +2515,26 @@ function InfiniteCanvasPage() {
</Modal>
) : null}
{lightingNode?.metadata?.content ? (
<Modal
open
centered
title="打光效果"
footer={null}
width={740}
destroyOnHidden
onCancel={() => setLightingNodeId(null)}
>
<CanvasNodeLightingPanel
dataUrl={lightingNode.metadata.content}
onClose={() => setLightingNodeId(null)}
onConfirm={(options, prompt) => {
void generateLightingNode(lightingNode, options, prompt);
}}
/>
</Modal>
) : null}
{emotionNode?.metadata?.content && !isCanvasNodeMoving ? (
<CanvasEmotionWorkspace
node={emotionNode}
@@ -2608,6 +2636,10 @@ function InfiniteCanvasPage() {
setDialogNodeId(null);
setAngleNodeId((current) => (current === node.id ? null : node.id));
}}
onLighting={(node) => {
setDialogNodeId(null);
setLightingNodeId((current) => (current === node.id ? null : node.id));
}}
onViewImage={(node) => setPreviewNodeId(node.id)}
onExtractVideoFrames={openVideoFrameExtractor}
onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}

View File

@@ -275,7 +275,7 @@ export default function SharedCanvasPage() {
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
</InfiniteCanvas>
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onFitContent={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
<div className="pointer-events-none absolute bottom-5 right-5 z-[var(--z-panel-floating)] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>

View File

@@ -7,6 +7,7 @@ import type { CanvasImageMaskEditPayload } from "@/components/canvas/canvas-node
import type { CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog";
import type { CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import { buildLightingLabel, type CanvasImageLightingOptions } from "@/components/canvas/canvas-node-lighting-dialog";
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
import type { CanvasVideoFrameParams } from "@/components/canvas/canvas-video-frame-dialog";
import type { CanvasVideoSegmentParams } from "@/components/canvas/canvas-video-segment-dialog";
@@ -109,6 +110,7 @@ export function useCanvasMediaTools({
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
const [upscaleNodeId, setUpscaleNodeId] = useState<string | null>(null);
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
const [lightingNodeId, setLightingNodeId] = useState<string | null>(null);
const [emotionNodeId, setEmotionNodeId] = useState<string | null>(null);
const [frameDialogNodeId, setFrameDialogNodeId] = useState<string | null>(null);
const [extractingVideoFramesNodeId, setExtractingVideoFramesNodeId] = useState<string | null>(null);
@@ -785,6 +787,50 @@ export function useCanvasMediaTools({
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
const generateLightingNode = useCallback(async (node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
const childId = nanoid();
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const title = buildLightingLabel(options);
const source = nodeReferenceImage(node);
if (!source) return;
const styleExecution = resolveImageEditStyle(node, prompt, generationConfig);
if (!styleExecution) return;
const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
setLightingNodeId(null);
setRunningNodeId(childId);
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: imageSpec.width, height: imageSpec.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata } }]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
setDialogNodeId(childId);
const controller = startGenerationRequest(childId, node.id, childId);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [source], signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "lighting", lighting: options, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploaded = await uploadImage(image.dataUrl);
const size = fitNodeSize(uploaded.width, uploaded.height, imageSpec.width, imageSpec.height);
const currentNode = nodesRef.current.find((item) => item.id === childId);
if (!currentNode) throw new Error("打光生成节点已被删除");
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
await persistMediaNodes([finalizedNode]);
} catch (error) {
if (isGenerationCanceled(error)) return;
const details = generationErrorMessage(error);
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
} finally {
finishGenerationRequest(childId, controller);
setRunningNodeId(null);
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
const generateEmotionNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageEmotionPayload) => {
if (!node.metadata?.content) return;
const baseConfig = buildGenerationConfig(effectiveConfig, node, "image");
@@ -850,6 +896,7 @@ export function useCanvasMediaTools({
return {
angleNodeId,
lightingNodeId,
emotionNodeId,
annotationNodeId,
createImageReversePromptNodes,
@@ -864,6 +911,7 @@ export function useCanvasMediaTools({
frameDialogNodeId,
handleSegmentConfirm,
generateAngleNode,
generateLightingNode,
maskEditImageNode,
maskEditNodeId,
mergeSelectedVideos,
@@ -876,6 +924,7 @@ export function useCanvasMediaTools({
setFrameDialogNodeId,
setSegmentDialogNodeId,
setAngleNodeId,
setLightingNodeId,
generateEmotionNode,
setEmotionNodeId,
setAnnotationNodeId,

View File

@@ -35,6 +35,7 @@ type UseCanvasRenderModelOptions = {
upscaleNodeId: string | null;
superResolveNodeId: string | null;
angleNodeId: string | null;
lightingNodeId: string | null;
emotionNodeId: string | null;
previewNodeId: string | null;
contextMenu: ContextMenuState | null;
@@ -65,6 +66,7 @@ export function useCanvasRenderModel({
upscaleNodeId,
superResolveNodeId,
angleNodeId,
lightingNodeId,
emotionNodeId,
previewNodeId,
contextMenu,
@@ -225,6 +227,7 @@ export function useCanvasRenderModel({
const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null;
const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null;
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
const lightingNode = lightingNodeId ? nodeById.get(lightingNodeId) || null : null;
const emotionNode = emotionNodeId ? nodeById.get(emotionNodeId) || null : null;
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
const contextMenuNode = contextMenu?.type === "node" ? nodeById.get(contextMenu.nodeId) || null : null;
@@ -361,6 +364,7 @@ export function useCanvasRenderModel({
activeScriptNode,
activeStylePresetId,
angleNode,
lightingNode,
emotionNode,
annotationNode,
batchChildCountById,