feat(canvas): 人物质感调节 - 支持图片引用与五维质感生成参数
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
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";
|
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 ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
|
||||||
|
|
||||||
export type ImageToolHandlers = {
|
export type ImageToolHandlers = {
|
||||||
@@ -12,6 +12,7 @@ export type ImageToolHandlers = {
|
|||||||
onAnnotate: (node: CanvasNodeData) => void;
|
onAnnotate: (node: CanvasNodeData) => void;
|
||||||
onMaskEdit: (node: CanvasNodeData) => void;
|
onMaskEdit: (node: CanvasNodeData) => void;
|
||||||
onEmotion: (node: CanvasNodeData) => void;
|
onEmotion: (node: CanvasNodeData) => void;
|
||||||
|
onPortraitTexture: (node: CanvasNodeData) => void;
|
||||||
onCrop: (node: CanvasNodeData) => void;
|
onCrop: (node: CanvasNodeData) => void;
|
||||||
onSplit: (node: CanvasNodeData) => void;
|
onSplit: (node: CanvasNodeData) => void;
|
||||||
onUpscale: (node: CanvasNodeData) => void;
|
onUpscale: (node: CanvasNodeData) => void;
|
||||||
@@ -33,7 +34,7 @@ export type ImageToolDefinition = {
|
|||||||
run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void;
|
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"];
|
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"];
|
||||||
|
|
||||||
@@ -102,6 +103,15 @@ export const imageToolDefinitions: ImageToolDefinition[] = [
|
|||||||
icon: () => <Smile className="size-3.5" />,
|
icon: () => <Smile className="size-3.5" />,
|
||||||
run: (node, handlers) => handlers.onEmotion(node),
|
run: (node, handlers) => handlers.onEmotion(node),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "portraitTexture",
|
||||||
|
defaultVisible: true,
|
||||||
|
panelLabel: "人物质感调节",
|
||||||
|
label: "人物质感",
|
||||||
|
title: "调节人景融合、光影、皮肤、纹理与锐度",
|
||||||
|
icon: () => <SlidersHorizontal className="size-3.5" />,
|
||||||
|
run: (node, handlers) => handlers.onPortraitTexture(node),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "crop",
|
id: "crop",
|
||||||
defaultVisible: true,
|
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 {
|
export function isImageQuickToolId(value: string): value is ImageQuickToolId {
|
||||||
return defaultBaseToolIds.some((id) => id === value) || imageToolDefinitions.some((tool) => tool.id === value);
|
return defaultBaseToolIds.some((id) => id === value) || imageToolDefinitions.some((tool) => tool.id === value);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type CanvasNodeHoverToolbarProps = {
|
|||||||
onSaveAsset: (node: CanvasNodeData) => void;
|
onSaveAsset: (node: CanvasNodeData) => void;
|
||||||
onMaskEdit: (node: CanvasNodeData) => void;
|
onMaskEdit: (node: CanvasNodeData) => void;
|
||||||
onEmotion: (node: CanvasNodeData) => void;
|
onEmotion: (node: CanvasNodeData) => void;
|
||||||
|
onPortraitTexture: (node: CanvasNodeData) => void;
|
||||||
onCrop: (node: CanvasNodeData) => void;
|
onCrop: (node: CanvasNodeData) => void;
|
||||||
onSplit: (node: CanvasNodeData) => void;
|
onSplit: (node: CanvasNodeData) => void;
|
||||||
onUpscale: (node: CanvasNodeData) => void;
|
onUpscale: (node: CanvasNodeData) => void;
|
||||||
@@ -93,6 +94,7 @@ export function CanvasNodeHoverToolbar({
|
|||||||
onSaveAsset,
|
onSaveAsset,
|
||||||
onMaskEdit,
|
onMaskEdit,
|
||||||
onEmotion,
|
onEmotion,
|
||||||
|
onPortraitTexture,
|
||||||
onCrop,
|
onCrop,
|
||||||
onSplit,
|
onSplit,
|
||||||
onUpscale,
|
onUpscale,
|
||||||
@@ -209,7 +211,7 @@ export function CanvasNodeHoverToolbar({
|
|||||||
}
|
}
|
||||||
copyText(prompt, "提示词已复制");
|
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() {
|
function openImageToolSettings() {
|
||||||
onKeep(activeNode.id);
|
onKeep(activeNode.id);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
|
|||||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||||
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
|
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
|
||||||
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
|
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 { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas";
|
||||||
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
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 composerHeight = Math.min(144, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
|
||||||
const isSubmitDisabled = !isRunning && !prompt.trim();
|
const isSubmitDisabled = !isRunning && !prompt.trim();
|
||||||
const canExpandPrompt = mode === "image" || mode === "video";
|
const canExpandPrompt = mode === "image" || mode === "video";
|
||||||
|
const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
|
||||||
const updatePromptContentHeight = useCallback((height: number) => {
|
const updatePromptContentHeight = useCallback((height: number) => {
|
||||||
setPromptContentHeight((current) => Math.abs(current - height) < 1 ? current : height);
|
setPromptContentHeight((current) => Math.abs(current - height) < 1 ? current : height);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -121,12 +123,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
|||||||
|
|
||||||
const renderComposerHeader = (expanded: boolean) => (
|
const renderComposerHeader = (expanded: boolean) => (
|
||||||
<div className="flex min-w-0 items-center gap-1 px-0.5">
|
<div className="flex min-w-0 items-center gap-1 px-0.5">
|
||||||
<div className="flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5" style={{ background: theme.toolbar.itemHover }}>
|
{isPortraitTexture ? (
|
||||||
<span className="grid size-3.5 shrink-0 place-items-center" style={{ color: theme.accent.primary }}>
|
<CanvasPortraitTexturePopover
|
||||||
<GenerationModeIcon mode={mode} />
|
value={node.metadata?.portraitTexture}
|
||||||
</span>
|
placement={expanded ? "topRight" : "topLeft"}
|
||||||
<span className="truncate text-[10px] font-medium">{modeDisplayName(mode)}创作</span>
|
onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })}
|
||||||
</div>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5" style={{ background: theme.toolbar.itemHover }}>
|
||||||
|
<span className="grid size-3.5 shrink-0 place-items-center" style={{ color: theme.accent.primary }}>
|
||||||
|
<GenerationModeIcon mode={mode} />
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[10px] font-medium">{modeDisplayName(mode)}创作</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!simpleMode ? (
|
{!simpleMode ? (
|
||||||
<CanvasPresetPicker
|
<CanvasPresetPicker
|
||||||
mode={mode}
|
mode={mode}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Popover } from "antd";
|
||||||
|
import { SlidersHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import {
|
||||||
|
PORTRAIT_TEXTURE_GROUPS,
|
||||||
|
normalizePortraitTextureSettings,
|
||||||
|
type PortraitTextureSettingKey,
|
||||||
|
type PortraitTextureSettings,
|
||||||
|
} from "@/lib/canvas/canvas-portrait-texture";
|
||||||
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
|
||||||
|
type CanvasPortraitTexturePopoverProps = {
|
||||||
|
value: unknown;
|
||||||
|
placement?: "topLeft" | "topRight";
|
||||||
|
onChange: (settings: PortraitTextureSettings) => 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 = (
|
||||||
|
<div className="w-[min(350px,calc(100vw-32px))] p-2.5" style={{ color: theme.node.text }}>
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 px-0.5">
|
||||||
|
<SlidersHorizontal className="size-3.5" style={{ color: theme.accent.primary }} />
|
||||||
|
<span className="text-xs font-medium">人物质感调节</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{PORTRAIT_TEXTURE_GROUPS.map((group) => (
|
||||||
|
<div key={group.key} className="grid grid-cols-[60px_minmax(0,1fr)] items-center gap-2 py-1">
|
||||||
|
<span className="text-[11px]" style={{ color: theme.node.muted }}>{group.label}</span>
|
||||||
|
<div className="grid min-w-0 grid-cols-3 gap-1" role="radiogroup" aria-label={group.label}>
|
||||||
|
{group.options.map((option) => {
|
||||||
|
const selected = settings[group.key] === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
className="h-7 min-w-0 rounded-md border px-1 text-[10px] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 motion-reduce:transition-none"
|
||||||
|
style={{
|
||||||
|
background: selected ? theme.accent.primarySoft : theme.toolbar.itemHover,
|
||||||
|
borderColor: selected ? theme.accent.primary : "transparent",
|
||||||
|
color: selected ? theme.accent.primary : theme.node.muted,
|
||||||
|
outlineColor: theme.accent.primary,
|
||||||
|
}}
|
||||||
|
onClick={() => updateSetting(group.key, option.value)}
|
||||||
|
>
|
||||||
|
<span className="block truncate">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement={placement}
|
||||||
|
arrow={false}
|
||||||
|
content={content}
|
||||||
|
styles={{ content: { padding: 0, overflow: "hidden", background: theme.spatial.elevated, border: `1px solid ${theme.toolbar.border}`, borderRadius: 8, boxShadow: `0 20px 64px ${theme.spatial.shadow}` } }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5 transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 motion-reduce:transition-none"
|
||||||
|
style={{ background: theme.accent.primarySoft, color: theme.accent.primary, outlineColor: theme.accent.primary }}
|
||||||
|
aria-label="打开人物质感调节面板"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="size-3 shrink-0" />
|
||||||
|
<span className="truncate text-[10px] font-medium">人物质感调节</span>
|
||||||
|
</button>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
web/src/lib/canvas/canvas-portrait-texture.ts
Normal file
110
web/src/lib/canvas/canvas-portrait-texture.ts
Normal file
@@ -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<string, unknown> : {};
|
||||||
|
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 extends PortraitTextureSettingKey>(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];
|
||||||
|
}
|
||||||
@@ -502,6 +502,7 @@ function InfiniteCanvasPage() {
|
|||||||
emotionNodeId,
|
emotionNodeId,
|
||||||
annotationNodeId,
|
annotationNodeId,
|
||||||
createImageReversePromptNodes,
|
createImageReversePromptNodes,
|
||||||
|
createPortraitTextureNode,
|
||||||
cropImageNode,
|
cropImageNode,
|
||||||
cropNodeId,
|
cropNodeId,
|
||||||
extractVideoLastFrame,
|
extractVideoLastFrame,
|
||||||
@@ -1525,6 +1526,7 @@ function InfiniteCanvasPage() {
|
|||||||
onAnnotate={(node) => setAnnotationNodeId(node.id)}
|
onAnnotate={(node) => setAnnotationNodeId(node.id)}
|
||||||
onMaskEdit={(node) => setMaskEditNodeId(node.id)}
|
onMaskEdit={(node) => setMaskEditNodeId(node.id)}
|
||||||
onEmotion={(node) => { setDialogNodeId(null); setEmotionNodeId((current) => current === node.id ? null : node.id); }}
|
onEmotion={(node) => { setDialogNodeId(null); setEmotionNodeId((current) => current === node.id ? null : node.id); }}
|
||||||
|
onPortraitTexture={createPortraitTextureNode}
|
||||||
onCrop={(node) => setCropNodeId(node.id)}
|
onCrop={(node) => setCropNodeId(node.id)}
|
||||||
onSplit={(node) => setSplitNodeId(node.id)}
|
onSplit={(node) => setSplitNodeId(node.id)}
|
||||||
onUpscale={(node) => setUpscaleNodeId(node.id)}
|
onUpscale={(node) => setUpscaleNodeId(node.id)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/comp
|
|||||||
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||||
import { buildGenerationConfig, isGenerationCanceled, supportsVideoReferenceAudio } from "@/lib/canvas/canvas-project-generation";
|
import { buildGenerationConfig, isGenerationCanceled, supportsVideoReferenceAudio } from "@/lib/canvas/canvas-project-generation";
|
||||||
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
|
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 { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||||
import { generationFailureMetadata } from "@/lib/generation-error";
|
import { generationFailureMetadata } from "@/lib/generation-error";
|
||||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||||
@@ -80,6 +81,9 @@ export function useCanvasGenerationExecutor({
|
|||||||
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
|
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
|
||||||
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
|
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
|
||||||
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
|
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;
|
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
|
||||||
if (isPreparingEmptyImage) {
|
if (isPreparingEmptyImage) {
|
||||||
setNodes((current) =>
|
setNodes((current) =>
|
||||||
@@ -107,7 +111,7 @@ export function useCanvasGenerationExecutor({
|
|||||||
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
|
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
|
||||||
try {
|
try {
|
||||||
rawGenerationContext = await hydrateNodeGenerationContext(
|
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,
|
projectId,
|
||||||
domainProjectId,
|
domainProjectId,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
supportsVideoReferenceAudio,
|
supportsVideoReferenceAudio,
|
||||||
} from "@/lib/canvas/canvas-project-generation";
|
} from "@/lib/canvas/canvas-project-generation";
|
||||||
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||||
|
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
|
||||||
import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error";
|
import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error";
|
||||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||||
import { storeGeneratedAudio } from "@/services/api/audio";
|
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 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)) {
|
if (unchangedModeratedPrompt(node.metadata, retryPromptSource)) {
|
||||||
message.warning("该提示词未通过内容审核,请先修改提示词再重新生成");
|
message.warning("该提示词未通过内容审核,请先修改提示词再重新生成");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let rawContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>> | null;
|
let rawContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>> | null;
|
||||||
try {
|
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));
|
rawContext = hasSavedImageMetadata && !baseContext.characterReferences.length ? null : await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const failure = generationFailureMetadata(error, retryPromptSource);
|
const failure = generationFailureMetadata(error, retryPromptSource);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "@/lib/canvas/canvas-project-generation";
|
} from "@/lib/canvas/canvas-project-generation";
|
||||||
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
||||||
import { compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canvas-emotion";
|
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 { captureVideoLastFrame } from "@/lib/canvas/canvas-video-frame";
|
||||||
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
|
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
|
||||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||||
@@ -129,6 +130,42 @@ export function useCanvasMediaTools({
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}, [effectiveConfig.model, effectiveConfig.textModel, message, setConnections, setContextMenu, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
}, [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) => {
|
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
|
||||||
if (!node.metadata?.content) return;
|
if (!node.metadata?.content) return;
|
||||||
const cropped = await cropDataUrl(node.metadata.content, crop);
|
const cropped = await cropDataUrl(node.metadata.content, crop);
|
||||||
@@ -431,6 +468,7 @@ export function useCanvasMediaTools({
|
|||||||
emotionNodeId,
|
emotionNodeId,
|
||||||
annotationNodeId,
|
annotationNodeId,
|
||||||
createImageReversePromptNodes,
|
createImageReversePromptNodes,
|
||||||
|
createPortraitTextureNode,
|
||||||
cropImageNode,
|
cropImageNode,
|
||||||
cropNodeId,
|
cropNodeId,
|
||||||
extractVideoLastFrame,
|
extractVideoLastFrame,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture";
|
||||||
|
|
||||||
export type Position = {
|
export type Position = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -254,6 +256,7 @@ export type CanvasNodeMetadata = {
|
|||||||
providerSize?: string;
|
providerSize?: string;
|
||||||
maskStorageKey?: string;
|
maskStorageKey?: string;
|
||||||
};
|
};
|
||||||
|
portraitTexture?: PortraitTextureSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasNodeData = {
|
export type CanvasNodeData = {
|
||||||
|
|||||||
Reference in New Issue
Block a user