From 2c808e41bf8515f63ccfdf083ae65871b29d5bce Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 30 Jul 2026 21:28:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E4=BA=BA=E7=89=A9=E8=B4=A8?= =?UTF-8?q?=E6=84=9F=E8=B0=83=E8=8A=82=20-=20=E6=94=AF=E6=8C=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=BC=95=E7=94=A8=E4=B8=8E=E4=BA=94=E7=BB=B4=E8=B4=A8?= =?UTF-8?q?=E6=84=9F=E7=94=9F=E6=88=90=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas/canvas-image-toolbar-tools.tsx | 18 ++- .../canvas/canvas-node-hover-toolbar.tsx | 4 +- .../canvas/canvas-node-prompt-panel.tsx | 22 +++- .../canvas-portrait-texture-popover.tsx | 85 ++++++++++++++ web/src/lib/canvas/canvas-portrait-texture.ts | 110 ++++++++++++++++++ web/src/pages/canvas/project.tsx | 2 + .../canvas/use-canvas-generation-executor.ts | 6 +- .../canvas/use-canvas-generation-retry.ts | 6 +- .../pages/canvas/use-canvas-media-tools.ts | 38 ++++++ web/src/types/canvas.ts | 3 + 10 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 web/src/components/canvas/canvas-portrait-texture-popover.tsx create mode 100644 web/src/lib/canvas/canvas-portrait-texture.ts diff --git a/web/src/components/canvas/canvas-image-toolbar-tools.tsx b/web/src/components/canvas/canvas-image-toolbar-tools.tsx index 45f4cce79..a83e3cd27 100644 --- a/web/src/components/canvas/canvas-image-toolbar-tools.tsx +++ b/web/src/components/canvas/canvas-image-toolbar-tools.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from "react"; -import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Scissors, Smile, Sparkles, Upload, ZoomIn } from "lucide-react"; +import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Scissors, SlidersHorizontal, Smile, Sparkles, Upload, ZoomIn } from "lucide-react"; import type { CanvasNodeData } from "@/types/canvas"; -export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view"; +export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view"; export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId; export type ImageToolHandlers = { @@ -12,6 +12,7 @@ export type ImageToolHandlers = { onAnnotate: (node: CanvasNodeData) => void; onMaskEdit: (node: CanvasNodeData) => void; onEmotion: (node: CanvasNodeData) => void; + onPortraitTexture: (node: CanvasNodeData) => void; onCrop: (node: CanvasNodeData) => void; onSplit: (node: CanvasNodeData) => void; onUpscale: (node: CanvasNodeData) => void; @@ -33,7 +34,7 @@ export type ImageToolDefinition = { run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void; }; -export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v12"; +export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v13"; const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"]; @@ -102,6 +103,15 @@ export const imageToolDefinitions: ImageToolDefinition[] = [ icon: () => , run: (node, handlers) => handlers.onEmotion(node), }, + { + id: "portraitTexture", + defaultVisible: true, + panelLabel: "人物质感调节", + label: "人物质感", + title: "调节人景融合、光影、皮肤、纹理与锐度", + icon: () => , + run: (node, handlers) => handlers.onPortraitTexture(node), + }, { id: "crop", defaultVisible: true, @@ -158,7 +168,7 @@ export const imageToolDefinitions: ImageToolDefinition[] = [ }, ]; -export const defaultImageQuickToolIds: ImageQuickToolId[] = ["info", "download", "maskEdit", "emotion", "crop", "angle"]; +export const defaultImageQuickToolIds: ImageQuickToolId[] = ["info", "download", "maskEdit", "emotion", "portraitTexture", "crop", "angle"]; export function isImageQuickToolId(value: string): value is ImageQuickToolId { return defaultBaseToolIds.some((id) => id === value) || imageToolDefinitions.some((tool) => tool.id === value); diff --git a/web/src/components/canvas/canvas-node-hover-toolbar.tsx b/web/src/components/canvas/canvas-node-hover-toolbar.tsx index 24522a8b9..a7dba6b77 100644 --- a/web/src/components/canvas/canvas-node-hover-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-hover-toolbar.tsx @@ -33,6 +33,7 @@ type CanvasNodeHoverToolbarProps = { onSaveAsset: (node: CanvasNodeData) => void; onMaskEdit: (node: CanvasNodeData) => void; onEmotion: (node: CanvasNodeData) => void; + onPortraitTexture: (node: CanvasNodeData) => void; onCrop: (node: CanvasNodeData) => void; onSplit: (node: CanvasNodeData) => void; onUpscale: (node: CanvasNodeData) => void; @@ -93,6 +94,7 @@ export function CanvasNodeHoverToolbar({ onSaveAsset, onMaskEdit, onEmotion, + onPortraitTexture, onCrop, onSplit, onUpscale, @@ -209,7 +211,7 @@ export function CanvasNodeHoverToolbar({ } copyText(prompt, "提示词已复制"); }; - const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); + const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); function openImageToolSettings() { onKeep(activeNode.id); diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 22cd07a9c..8711bb547 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -15,6 +15,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools"; import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker"; +import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover"; import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas"; import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; @@ -62,6 +63,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const composerHeight = Math.min(144, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight))); const isSubmitDisabled = !isRunning && !prompt.trim(); const canExpandPrompt = mode === "image" || mode === "video"; + const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture); const updatePromptContentHeight = useCallback((height: number) => { setPromptContentHeight((current) => Math.abs(current - height) < 1 ? current : height); }, []); @@ -121,12 +123,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const renderComposerHeader = (expanded: boolean) => (
-
- - - - {modeDisplayName(mode)}创作 -
+ {isPortraitTexture ? ( + onConfigChange(node.id, { portraitTexture })} + /> + ) : ( +
+ + + + {modeDisplayName(mode)}创作 +
+ )} {!simpleMode ? ( void; +}; + +export function CanvasPortraitTexturePopover({ value, placement = "topLeft", onChange }: CanvasPortraitTexturePopoverProps) { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const settings = normalizePortraitTextureSettings(value); + + const updateSetting = (key: PortraitTextureSettingKey, nextValue: string) => { + onChange(normalizePortraitTextureSettings({ ...settings, [key]: nextValue })); + }; + + const content = ( +
+
+ + 人物质感调节 +
+
+ {PORTRAIT_TEXTURE_GROUPS.map((group) => ( +
+ {group.label} +
+ {group.options.map((option) => { + const selected = settings[group.key] === option.value; + return ( + + ); + })} +
+
+ ))} +
+
+ ); + + return ( + + + + ); +} diff --git a/web/src/lib/canvas/canvas-portrait-texture.ts b/web/src/lib/canvas/canvas-portrait-texture.ts new file mode 100644 index 000000000..e53eb5f83 --- /dev/null +++ b/web/src/lib/canvas/canvas-portrait-texture.ts @@ -0,0 +1,110 @@ +export type PortraitTextureSettings = { + personSceneFusion: "light" | "natural" | "deep"; + lightingFusion: "soft" | "natural" | "atmosphere"; + skin: "clear" | "natural" | "real"; + texture: "soft" | "natural" | "grain"; + sharpness: "soft" | "standard" | "high"; +}; + +export type PortraitTextureSettingKey = keyof PortraitTextureSettings; + +type PortraitTextureOption = { + value: string; + label: string; + prompt: string; +}; + +export type PortraitTextureGroup = { + key: PortraitTextureSettingKey; + label: string; + options: readonly PortraitTextureOption[]; +}; + +export const DEFAULT_PORTRAIT_TEXTURE_SETTINGS: PortraitTextureSettings = { + personSceneFusion: "deep", + lightingFusion: "natural", + skin: "natural", + texture: "natural", + sharpness: "standard", +}; + +export const PORTRAIT_TEXTURE_GROUPS: readonly PortraitTextureGroup[] = [ + { + key: "personSceneFusion", + label: "人景融合", + options: [ + { value: "light", label: "轻度对齐", prompt: "轻度对齐人物与场景,仅修正明显边缘和空间关系" }, + { value: "natural", label: "自然融合", prompt: "自然融合人物与场景,统一边缘、色调和空间关系" }, + { value: "deep", label: "深度融合", prompt: "深度融合人物与场景,细致统一边缘、环境色和空间层次" }, + ], + }, + { + key: "lightingFusion", + label: "光影融合", + options: [ + { value: "soft", label: "柔和补光", prompt: "使用柔和补光,减弱生硬阴影并保留自然明暗" }, + { value: "natural", label: "自然匹配", prompt: "让人物光向、色温与场景光线自然匹配" }, + { value: "atmosphere", label: "氛围强化", prompt: "强化环境光与氛围光,保持光影方向合理" }, + ], + }, + { + key: "skin", + label: "皮肤", + options: [ + { value: "clear", label: "清透修饰", prompt: "清透修饰皮肤,适度均匀肤色且不过度磨皮" }, + { value: "natural", label: "自然肤质", prompt: "保留自然肤质、毛孔和真实肤色过渡" }, + { value: "real", label: "真实肌理", prompt: "强化真实皮肤肌理和细微质感,避免塑料感" }, + ], + }, + { + key: "texture", + label: "纹理", + options: [ + { value: "soft", label: "柔和纹理", prompt: "使用柔和细腻的整体纹理,减少粗糙噪点" }, + { value: "natural", label: "自然纹理", prompt: "保持服装、头发、皮肤和场景材质的自然纹理" }, + { value: "grain", label: "颗粒质感", prompt: "增加克制的颗粒质感和材质层次" }, + ], + }, + { + key: "sharpness", + label: "锐度", + options: [ + { value: "soft", label: "柔焦", prompt: "使用轻柔焦效果,保持主体轮廓可辨" }, + { value: "standard", label: "标准清晰", prompt: "保持标准清晰度,细节自然且不过度锐化" }, + { value: "high", label: "高清锐化", prompt: "提升关键细节清晰度,避免锐化光晕和噪点" }, + ], + }, +] as const; + +export function normalizePortraitTextureSettings(value: unknown): PortraitTextureSettings { + const candidate = value && typeof value === "object" ? value as Record : {}; + return { + personSceneFusion: normalizeSetting("personSceneFusion", candidate.personSceneFusion), + lightingFusion: normalizeSetting("lightingFusion", candidate.lightingFusion), + skin: normalizeSetting("skin", candidate.skin), + texture: normalizeSetting("texture", candidate.texture), + sharpness: normalizeSetting("sharpness", candidate.sharpness), + }; +} + +export function buildPortraitTexturePrompt(userPrompt: string, value: unknown) { + const settings = normalizePortraitTextureSettings(value); + const instructions = PORTRAIT_TEXTURE_GROUPS.map((group) => { + const selected = group.options.find((option) => option.value === settings[group.key]); + return `${group.label}(${selected?.label || "默认"}):${selected?.prompt || "保持自然"}`; + }); + + return [ + userPrompt.trim(), + "请基于参考图片进行人物质感调节,只优化下列视觉属性:", + ...instructions.map((instruction) => `- ${instruction}`), + "必须保持原人物身份、五官、发型、服装、姿势、构图、场景和画面比例;不得新增或删除主体,不得添加文字、水印或标志。", + ].filter(Boolean).join("\n"); +} + +function normalizeSetting(key: Key, value: unknown): PortraitTextureSettings[Key] { + const group = PORTRAIT_TEXTURE_GROUPS.find((item) => item.key === key); + return group?.options.some((option) => option.value === value) + ? value as PortraitTextureSettings[Key] + : DEFAULT_PORTRAIT_TEXTURE_SETTINGS[key]; +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index d72990149..da77d277b 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -502,6 +502,7 @@ function InfiniteCanvasPage() { emotionNodeId, annotationNodeId, createImageReversePromptNodes, + createPortraitTextureNode, cropImageNode, cropNodeId, extractVideoLastFrame, @@ -1525,6 +1526,7 @@ function InfiniteCanvasPage() { onAnnotate={(node) => setAnnotationNodeId(node.id)} onMaskEdit={(node) => setMaskEditNodeId(node.id)} onEmotion={(node) => { setDialogNodeId(null); setEmotionNodeId((current) => current === node.id ? null : node.id); }} + onPortraitTexture={createPortraitTextureNode} onCrop={(node) => setCropNodeId(node.id)} onSplit={(node) => setSplitNodeId(node.id)} onUpscale={(node) => setUpscaleNodeId(node.id)} diff --git a/web/src/pages/canvas/use-canvas-generation-executor.ts b/web/src/pages/canvas/use-canvas-generation-executor.ts index 33e235b7d..ee6104b2b 100644 --- a/web/src/pages/canvas/use-canvas-generation-executor.ts +++ b/web/src/pages/canvas/use-canvas-generation-executor.ts @@ -5,6 +5,7 @@ import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/comp import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import { buildGenerationConfig, isGenerationCanceled, supportsVideoReferenceAudio } from "@/lib/canvas/canvas-project-generation"; import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch"; +import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture"; import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions"; import { generationFailureMetadata } from "@/lib/generation-error"; import { navigateToSettings } from "@/lib/settings-navigation"; @@ -80,6 +81,9 @@ export function useCanvasGenerationExecutor({ const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller); const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : ""; const editingTextNode = mode === "text" && Boolean(sourceTextContent); + const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture + ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) + : prompt; const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content; if (isPreparingEmptyImage) { setNodes((current) => @@ -107,7 +111,7 @@ export function useCanvasGenerationExecutor({ let rawGenerationContext: Awaited>; try { rawGenerationContext = await hydrateNodeGenerationContext( - buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : prompt), + buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt), projectId, domainProjectId, mode, diff --git a/web/src/pages/canvas/use-canvas-generation-retry.ts b/web/src/pages/canvas/use-canvas-generation-retry.ts index 9a46e4916..d187d93f8 100644 --- a/web/src/pages/canvas/use-canvas-generation-retry.ts +++ b/web/src/pages/canvas/use-canvas-generation-retry.ts @@ -22,6 +22,7 @@ import { supportsVideoReferenceAudio, } from "@/lib/canvas/canvas-project-generation"; import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions"; +import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture"; import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error"; import { navigateToSettings } from "@/lib/settings-navigation"; import { storeGeneratedAudio } from "@/services/api/audio"; @@ -77,13 +78,16 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, activated } const retryPromptSource = sourceNode.metadata?.composerContent || sourceNode.metadata?.prompt || node.metadata?.prompt || ""; + const retryContextPrompt = retryMode === "image" && sourceNode.metadata?.portraitTexture + ? buildPortraitTexturePrompt(retryPromptSource, sourceNode.metadata.portraitTexture) + : retryPromptSource; if (unchangedModeratedPrompt(node.metadata, retryPromptSource)) { message.warning("该提示词未通过内容审核,请先修改提示词再重新生成"); return; } let rawContext: Awaited> | null; try { - const baseContext = buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, retryPromptSource); + const baseContext = buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, retryContextPrompt); rawContext = hasSavedImageMetadata && !baseContext.characterReferences.length ? null : await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig)); } catch (error) { const failure = generationFailureMetadata(error, retryPromptSource); diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts index 2a18bd152..28f81ec77 100644 --- a/web/src/pages/canvas/use-canvas-media-tools.ts +++ b/web/src/pages/canvas/use-canvas-media-tools.ts @@ -21,6 +21,7 @@ import { } from "@/lib/canvas/canvas-project-generation"; import { fitNodeSize } from "@/lib/canvas/canvas-node-size"; import { compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canvas-emotion"; +import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture"; import { captureVideoLastFrame } from "@/lib/canvas/canvas-video-frame"; import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge"; import { navigateToSettings } from "@/lib/settings-navigation"; @@ -129,6 +130,42 @@ export function useCanvasMediaTools({ setContextMenu(null); }, [effectiveConfig.model, effectiveConfig.textModel, message, setConnections, setContextMenu, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); + const createPortraitTextureNode = useCallback((node: CanvasNodeData) => { + if (node.type !== CanvasNodeType.Image || !node.metadata?.content) { + message.warning("图片节点为空,无法调节人物质感"); + return; + } + const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; + const composerContent = `@[node:${node.id}]`; + const child = createCanvasNode( + CanvasNodeType.Image, + { + x: node.position.x + node.width + 96 + imageSpec.width / 2, + y: node.position.y + node.height / 2, + }, + { + generationMode: "image", + model: effectiveConfig.imageModel || effectiveConfig.model, + size: node.metadata.size || effectiveConfig.size, + quality: node.metadata.quality || effectiveConfig.quality, + transparentBackground: node.metadata.transparentBackground || effectiveConfig.transparentBackground, + count: 1, + prompt: composerContent, + composerContent, + portraitTexture: { ...DEFAULT_PORTRAIT_TEXTURE_SETTINGS }, + }, + ); + child.title = "人物质感调节"; + setNodes((current) => [...current, child]); + setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: child.id }]); + setSelectedNodeIds(new Set([child.id])); + setSelectedConnectionId(null); + setDialogNodeId(child.id); + setContextMenu(null); + setHoveredNodeId(null); + setToolbarNodeId(null); + }, [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.quality, effectiveConfig.size, effectiveConfig.transparentBackground, message, setConnections, setContextMenu, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]); + const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => { if (!node.metadata?.content) return; const cropped = await cropDataUrl(node.metadata.content, crop); @@ -431,6 +468,7 @@ export function useCanvasMediaTools({ emotionNodeId, annotationNodeId, createImageReversePromptNodes, + createPortraitTextureNode, cropImageNode, cropNodeId, extractVideoLastFrame, diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 94fd90e57..3030a9cc4 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -1,3 +1,5 @@ +import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture"; + export type Position = { x: number; y: number; @@ -254,6 +256,7 @@ export type CanvasNodeMetadata = { providerSize?: string; maskStorageKey?: string; }; + portraitTexture?: PortraitTextureSettings; }; export type CanvasNodeData = {