feat(canvas): 人物质感调节 - 支持图片引用与五维质感生成参数
This commit is contained in:
@@ -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: () => <Smile className="size-3.5" />,
|
||||
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",
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => (
|
||||
<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 }}>
|
||||
<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>
|
||||
{isPortraitTexture ? (
|
||||
<CanvasPortraitTexturePopover
|
||||
value={node.metadata?.portraitTexture}
|
||||
placement={expanded ? "topRight" : "topLeft"}
|
||||
onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })}
|
||||
/>
|
||||
) : (
|
||||
<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 ? (
|
||||
<CanvasPresetPicker
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user