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

@@ -1,10 +1,10 @@
import type { ReactNode } from "react";
import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Upload, Scaling } from "lucide-react";
import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Sun, Upload, Scaling } from "lucide-react";
import type { CanvasNodeData } from "@/types/canvas";
import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "view";
type ImageToolHandlers = {
onUpload: (node: CanvasNodeData) => void;
@@ -18,6 +18,7 @@ type ImageToolHandlers = {
onUpscale: (node: CanvasNodeData) => void;
onSuperResolve: (node: CanvasNodeData) => void;
onAngle: (node: CanvasNodeData) => void;
onLighting: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onCopyPrompt: (node: CanvasNodeData) => void;
onReversePrompt: (node: CanvasNodeData) => void;
@@ -152,6 +153,16 @@ const imageToolDefinitions: ImageToolDefinition[] = [
order: 20,
run: (node, handlers) => handlers.onAngle(node),
},
{
id: "lighting",
label: "打光",
section: "视角",
description: "调整光线方向、亮度与光效,生成新图片",
icon: () => <Sun className="size-3.5" />,
group: "viewpoint",
order: 30,
run: (node, handlers) => handlers.onLighting(node),
},
{
id: "view",
label: "预览",

View File

@@ -0,0 +1,649 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Slider } from "antd";
import { motion, useReducedMotion } from "motion/react";
import { RotateCcw, Send, Sun, X } from "lucide-react";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { Tooltip } from "@/components/ui/base/tooltip";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasThemes } from "@/lib/canvas-theme";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
export type CanvasImageLightingOptions = {
azimuth: number;
elevation: number;
brightness: number;
rimLight: boolean;
stylePreset: string;
smartMode: boolean;
lightColor: string;
};
const defaultLightingOptions: CanvasImageLightingOptions = { azimuth: 0, elevation: 0, brightness: 50, rimLight: false, stylePreset: "", smartMode: true, lightColor: "#ffffff" };
const LIGHT_POSITIONS = [
{ label: "左侧", azimuth: 270, elevation: 0 },
{ label: "顶部", azimuth: 0, elevation: 80 },
{ label: "右侧", azimuth: 90, elevation: 0 },
{ label: "前方", azimuth: 0, elevation: 0 },
{ label: "底部", azimuth: 0, elevation: -80 },
{ label: "后方", azimuth: 180, elevation: 0 },
];
const STYLE_PRESETS = [
{ id: "overexposed", name: "过曝胶片", color: "#d4b896", image: "/lighting-presets/overexposed.png", prompt: "overexposed film aesthetic, high-key lighting, washed out highlights, soft diffused light, vintage film look" },
{ id: "blueBacklight", name: "蓝色逆光", color: "#1a3a5c", image: "/lighting-presets/blue-backlight.png", prompt: "dramatic backlighting, blue rim light, cool color temperature, silhouette with colored edges, ethereal atmosphere" },
{ id: "rembrandt", name: "伦勃朗光", color: "#5a3a1a", image: "/lighting-presets/rembrandt.png", prompt: "Rembrandt lighting, 45-degree angle key light, dramatic chiaroscuro, painterly shadows, classical portraiture" },
{ id: "cyberpunk", name: "赛博朋克", color: "#2a0a2a", image: "/lighting-presets/cyberpunk.png", prompt: "cyberpunk neon lighting, synthetic glow, futuristic atmosphere, vibrant cyan and magenta neon" },
{ id: "sunset", name: "落日迷幻", color: "#7a3010", image: "/lighting-presets/sunset.png", prompt: "golden hour lighting, warm sunset tones, long shadow, romantic atmosphere, Kodachrome colors" },
{ id: "mysterious", name: "神秘暗调", color: "#0a0a14", image: "/lighting-presets/mysterious.png", prompt: "low-key noir lighting, deep shadows, mysterious mood, film noir style, high contrast cinematic" },
{ id: "goldenHour", name: "黄金时刻", color: "#7a5a00", image: "/lighting-presets/golden-hour.png", prompt: "golden hour photography, warm soft light, beautiful catchlights, lens flare, magical golden glow" },
{ id: "nolanGrey", name: "诺兰冷灰", color: "#1a2a2a", image: "/lighting-presets/nolan-grey.png", prompt: "Christopher Nolan cinematography, IMAX quality, desaturated cold palette, teal and grey grading" },
];
const DEFAULT_LIGHTING_PROMPT_TEMPLATE = [
"{{consistencyPrompt}}",
"{{presetPrompt}}",
"{{smartDesc}}",
"{{lightDirectionPrompt}}",
"{{brightnessPrompt}}",
"{{rimLightPrompt}}",
"{{lightColorPrompt}}",
"{{lightingMeta}}",
].join(", ");
const LIGHTING_PROMPT_PLACEHOLDERS = [
"{{consistencyPrompt}}",
"{{presetPrompt}}",
"{{smartDesc}}",
"{{lightDirectionPrompt}}",
"{{brightnessPrompt}}",
"{{rimLightPrompt}}",
"{{lightColorPrompt}}",
"{{lightingMeta}}",
];
export function buildLightingLabel(options: CanvasImageLightingOptions) {
const preset = STYLE_PRESETS.find((item) => item.id === options.stylePreset);
const position = LIGHT_POSITIONS.find((item) => item.azimuth === options.azimuth && item.elevation === options.elevation);
const parts: string[] = [];
parts.push(preset?.name || (position ? `主光${position.label}` : `主光 ${options.azimuth}°/${options.elevation}°`));
if (options.brightness !== 50) parts.push(`亮度 ${options.brightness}%`);
if (options.rimLight) parts.push("轮廓光");
if (options.lightColor && options.lightColor.toLowerCase() !== "#ffffff") parts.push(`光色 ${options.lightColor}`);
return `AI 打光:${parts.join(",")}`;
}
function applyPromptTemplate(template: string, values: Record<string, string>) {
return template
.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => values[key] ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean)
.join(", ");
}
// Map azimuth/elevation to human-readable direction. Avoids raw "main light from azimuth 270°"
// which the model tends to ignore, and avoids language that would make the model literally
// paint a light fixture inside the frame.
function buildLightDirectionPrompt(azimuth: number, elevation: number) {
const azimuthDegrees = ((azimuth % 360) + 360) % 360;
if (elevation >= 60) return "strong top-down key light from directly above";
if (elevation <= -60) return "strong upward uplight from directly below the subject";
const azimuthName = azimuthDegrees >= 345 || azimuthDegrees <= 15
? "coming from the front"
: azimuthDegrees > 15 && azimuthDegrees < 75
? "coming from the front-right at 45 degrees"
: azimuthDegrees >= 75 && azimuthDegrees <= 105
? "coming from the right side, pure side-light"
: azimuthDegrees > 105 && azimuthDegrees < 165
? "coming from the back-right, creating rim and edge light"
: azimuthDegrees >= 165 && azimuthDegrees <= 195
? "coming from directly behind the subject, strong backlight and silhouette"
: azimuthDegrees > 195 && azimuthDegrees < 255
? "coming from the back-left, creating rim and edge light"
: azimuthDegrees >= 255 && azimuthDegrees <= 285
? "coming from the left side, pure side-light"
: "coming from the front-left at 45 degrees";
const elevationName = elevation >= 30
? ", tilted downward from above"
: elevation <= -30
? ", tilted upward from below"
: "";
return `main key light ${azimuthName}${elevationName}`;
}
function buildLightingPrompt(input: {
template: string;
consistencyPrompt: string;
presetPrompt: string;
smartDesc: string;
lightDirectionPrompt: string;
brightnessPrompt: string;
rimLightPrompt: string;
lightColorPrompt: string;
lightingMeta: string;
}) {
const template = input.template.includes("{{consistencyPrompt}}") ? input.template : `{{consistencyPrompt}}, ${input.template}`;
return applyPromptTemplate(template, {
consistencyPrompt: input.consistencyPrompt,
presetPrompt: input.presetPrompt,
smartDesc: input.smartDesc,
lightDirectionPrompt: input.lightDirectionPrompt,
brightnessPrompt: input.brightnessPrompt,
rimLightPrompt: input.rimLightPrompt,
lightColorPrompt: input.lightColorPrompt,
lightingMeta: input.lightingMeta,
});
}
export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataUrl: string; onClose: () => void; onConfirm: (options: CanvasImageLightingOptions, prompt: string) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const copyText = useCopyText();
const [options, setOptions] = useState(defaultLightingOptions);
const [smartDesc, setSmartDesc] = useState("");
const [viewMode, setViewMode] = useState<"perspective" | "front">("perspective");
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
const [promptTemplate, setPromptTemplate] = useState(DEFAULT_LIGHTING_PROMPT_TEMPLATE);
const updateOption = <Key extends keyof CanvasImageLightingOptions>(key: Key, value: CanvasImageLightingOptions[Key]) => setOptions((current) => ({ ...current, [key]: value }));
const buildPrompt = useCallback(() => {
const presetPrompt = options.stylePreset ? STYLE_PRESETS.find((preset) => preset.id === options.stylePreset)?.prompt ?? "" : "";
const smartDescPrompt = options.smartMode ? smartDesc.trim() : "";
const brightnessPrompt = options.brightness >= 75
? "high-key, bright and well-lit scene, lifted exposure"
: options.brightness <= 25
? "low-key, dim and moody scene, deep shadows, reduced exposure"
: options.brightness === 50
? ""
: options.brightness > 50
? "slightly brighter exposure"
: "slightly darker exposure";
const rimLightPrompt = options.rimLight ? "add clear rim light and edge highlight along the silhouette" : "";
const lightColorPrompt = options.lightColor && options.lightColor.toLowerCase() !== "#ffffff" ? `key light color temperature tinted toward ${options.lightColor}` : "";
const lightingMeta = `[lighting azimuth:${options.azimuth}° elevation:${options.elevation}° brightness:${options.brightness}%${options.rimLight ? " rim:on" : ""}]`;
const consistencyPrompt = [
"this is a lighting-only edit of the reference image: same scene, same people, same action, same clothing, same background, only the lighting changes",
"the following instructions describe how light falls on the subject, they are not new objects to add to the scene",
"do not add any lamp, spotlight, light fixture, reflector, softbox, torch, candle, or photography equipment into the image",
"preserve identity, face, outfit, hairstyle, body pose, and the background layout from the input image",
"if multiple people are present, preserve the same number of people and their spatial relationship",
"only change lighting direction, light color, brightness, shadows, contrast, and mood",
].join(", ");
return buildLightingPrompt({
template: promptTemplate,
consistencyPrompt,
presetPrompt,
smartDesc: smartDescPrompt,
lightDirectionPrompt: buildLightDirectionPrompt(options.azimuth, options.elevation),
brightnessPrompt,
rimLightPrompt,
lightColorPrompt,
lightingMeta,
});
}, [options, promptTemplate, smartDesc]);
const handleApply = () => onConfirm(options, buildPrompt());
const handleReset = () => {
setOptions(defaultLightingOptions);
setSmartDesc("");
setViewMode("perspective");
};
const isPositionActive = (position: { azimuth: number; elevation: number }) => position.azimuth === options.azimuth && position.elevation === options.elevation;
const secondaryButtonClass = "flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-medium transition hover:bg-black/5 dark:hover:bg-white/10";
return (
<SpotlightSurface
data-canvas-no-zoom
spotlightColor={theme.toolbar.itemHover}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
className="w-[700px] max-w-full overflow-hidden rounded-[var(--r-2xl)] border backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex p-0">
{/* Left col: view tabs + light sphere */}
<div className="flex flex-col items-center gap-2 border-r px-3 py-3" style={{ width: 185, borderColor: theme.toolbar.border }}>
<div className="mb-1 flex w-full gap-1 rounded-[var(--r-md)] p-1" style={{ background: theme.toolbar.itemHover }}>
{(["perspective", "front"] as const).map((mode) => (
<button
key={mode}
type="button"
aria-pressed={viewMode === mode}
onClick={() => setViewMode(mode)}
className="flex-1 rounded-[var(--r-md)] py-1 text-[11px] font-medium transition-colors"
style={viewMode === mode ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.muted }}
>
{mode === "perspective" ? "透视" : "正面"}
</button>
))}
</div>
<LightingSphereControl
azimuth={options.azimuth}
elevation={options.elevation}
onAngleChange={(azimuthValue, elevationValue) => setOptions((current) => ({ ...current, azimuth: azimuthValue, elevation: elevationValue }))}
previewImageUrl={dataUrl}
viewMode={viewMode}
/>
</div>
{/* Middle col: global controls */}
<div className="flex flex-col gap-3 border-r px-3 py-3" style={{ width: 210, borderColor: theme.toolbar.border }}>
<div className="flex items-center justify-between">
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>全局</span>
<PanelToggle label="智能模式" checked={options.smartMode} onChange={(checked) => updateOption("smartMode", checked)} theme={theme} />
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="w-6 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>亮度</span>
<div className="min-w-0 flex-1"><Slider min={0} max={100} value={options.brightness} onChange={(value) => updateOption("brightness", value)} tooltip={{ formatter: (value) => `${value}%` }} /></div>
<div className="flex items-center gap-1 rounded-[var(--r-md)] border px-2 py-1 text-[11px]" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.muted }}>
<Sun className="size-3" />
<span className="w-8 text-right">{options.brightness}%</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="w-6 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>颜色</span>
<div className="relative flex items-center">
<label
className="relative h-6 w-10 cursor-pointer overflow-hidden rounded border"
style={{ borderColor: theme.toolbar.border, background: options.lightColor === "#ffffff" ? "linear-gradient(135deg, #ff0000 0%, #ff9900 25%, #ffff00 50%, transparent 50%, transparent 100%), linear-gradient(45deg, #aaa 25%, transparent 25%, transparent 75%, #aaa 75%)" : options.lightColor }}
>
<input
type="color"
aria-label="光线颜色"
value={options.lightColor}
onChange={(event) => updateOption("lightColor", event.target.value)}
className="absolute left-0 top-full h-0 w-0 cursor-pointer opacity-0"
/>
</label>
{options.lightColor === "#ffffff" ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-px w-full rotate-45 bg-red-500" />
</div>
) : null}
</div>
</div>
<div className="space-y-1.5">
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>主光源</span>
<div className="grid grid-cols-3 gap-1">
{LIGHT_POSITIONS.map((position) => (
<button
key={position.label}
type="button"
aria-pressed={isPositionActive(position)}
onClick={() => setOptions((current) => ({ ...current, azimuth: position.azimuth, elevation: position.elevation }))}
className="rounded-[var(--r-md)] py-1.5 text-xs font-medium transition-colors"
style={isPositionActive(position) ? { background: theme.node.activeStroke, color: theme.node.panel } : { background: theme.toolbar.itemHover, color: theme.node.text }}
>
{position.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>轮廓光</span>
<PanelToggle label="轮廓光" checked={options.rimLight} onChange={(checked) => updateOption("rimLight", checked)} theme={theme} />
</div>
</div>
{/* Right col: smart description + presets */}
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>智能模式</span>
<textarea
value={smartDesc}
onChange={(event) => setSmartDesc(event.target.value)}
placeholder="简单描述你想要实现的打光效果,或者情绪风格"
disabled={!options.smartMode}
className="h-16 w-full resize-none rounded-[var(--r-lg)] border px-3 py-2 text-xs outline-none transition-colors"
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.text, opacity: options.smartMode ? 1 : 0.55 }}
/>
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>预设</span>
<div className="grid grid-cols-2 gap-1.5">
{STYLE_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
aria-pressed={options.stylePreset === preset.id}
onClick={() => updateOption("stylePreset", options.stylePreset === preset.id ? "" : preset.id)}
className={`relative h-[60px] overflow-hidden rounded-[var(--r-lg)] text-left transition-[box-shadow,filter,border-color,background-color] duration-150 ${options.stylePreset === preset.id ? "ring-2 ring-white/60 shadow-[0_0_0_1px_rgba(255,255,255,0.15)]" : "hover:ring-1 hover:ring-white/30"}`}
style={{
backgroundImage: `linear-gradient(to top, rgba(0,0,0,0.78), rgba(0,0,0,0.08)), url(${preset.image})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundColor: preset.color,
}}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_30%,rgba(255,255,255,0.22),transparent_36%)]" />
<span className="absolute bottom-1.5 left-2 right-2 text-[11px] font-medium leading-tight text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.55)]">{preset.name}</span>
</button>
))}
</div>
</div>
</div>
<div className="flex h-12 items-center gap-2 border-t px-3" style={{ borderColor: theme.toolbar.border }}>
<button type="button" className={secondaryButtonClass} style={{ color: theme.node.muted }} onClick={handleReset}><RotateCcw className="size-3.5" />重置参数</button>
<span className="flex-1" />
<Tooltip title="复制当前生成的打光提示词">
<button type="button" className={secondaryButtonClass} onClick={() => copyText(buildPrompt(), "打光提示词已复制")}>复制提示词</button>
</Tooltip>
<button type="button" className={secondaryButtonClass} onClick={() => setIsTemplateDialogOpen(true)}>设置提示词</button>
<motion.button
type="button"
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-semibold"
style={{ background: theme.node.activeStroke, color: theme.node.panel }}
onClick={handleApply}
>
<Send className="size-3.5" />生成打光效果
</motion.button>
</div>
<PromptTemplateDialog
isOpen={isTemplateDialogOpen}
title="设置打光默认提示词"
description="这里修改的是系统自动附加的默认打光描述骨架,不包含你当前选择的亮度、方向、颜色等参数值。面板参数会自动替换进去;智能模式里输入的文字会单独叠加。"
value={promptTemplate}
defaultValue={DEFAULT_LIGHTING_PROMPT_TEMPLATE}
placeholders={LIGHTING_PROMPT_PLACEHOLDERS}
theme={theme}
onClose={() => setIsTemplateDialogOpen(false)}
onChange={setPromptTemplate}
/>
</SpotlightSurface>
);
}
function PanelToggle({ label, checked, onChange, theme }: { label: string; checked: boolean; onChange: (checked: boolean) => void; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<div className="flex items-center gap-2">
<span className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{label}</span>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors"
style={{ background: checked ? theme.node.activeStroke : theme.toolbar.itemHover }}
>
<span className={`inline-block size-3.5 transform rounded-full shadow transition-transform ${checked ? "translate-x-4" : "translate-x-0.5"}`} style={{ background: checked ? theme.node.panel : theme.node.muted }} />
</button>
</div>
);
}
function PromptTemplateDialog({ isOpen, title, description, value, defaultValue, placeholders, theme, onClose, onChange }: { isOpen: boolean; title: string; description?: string; value: string; defaultValue: string; placeholders: string[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClose: () => void; onChange: (value: string) => void }) {
const [draft, setDraft] = useState(value);
useEffect(() => {
if (isOpen) setDraft(value);
}, [isOpen, value]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[1100] flex items-center justify-center bg-black/55 backdrop-blur-[2px]" data-canvas-no-zoom onClick={onClose}>
<div
className="w-[560px] max-w-[calc(100vw-32px)] rounded-[var(--r-xl)] border shadow-2xl"
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b px-5 py-3.5" style={{ borderColor: theme.toolbar.border }}>
<span className="text-sm font-semibold">{title}</span>
<button type="button" aria-label="关闭提示词设置" className="grid size-6 place-items-center rounded-[var(--r-md)] transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onClose}><X className="size-4" /></button>
</div>
<div className="space-y-3 px-5 py-4">
{description ? (
<div className="rounded-[var(--r-lg)] border px-3 py-2 text-xs leading-5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.muted }}>{description}</div>
) : null}
<div className="rounded-[var(--r-lg)] border px-3 py-2" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover }}>
<div className="text-[11px] font-medium" style={{ color: theme.node.muted }}>可用占位符</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{placeholders.map((placeholder) => (
<span key={placeholder} className="rounded-[var(--r-md)] px-2 py-1 text-[11px]" style={{ background: theme.toolbar.activeBg, color: theme.node.text }}>{placeholder}</span>
))}
</div>
</div>
<textarea
value={draft}
onChange={(event) => {
const nextValue = event.target.value;
setDraft(nextValue);
onChange(nextValue);
}}
className="h-48 w-full resize-none rounded-[var(--r-lg)] border px-3 py-2 text-sm leading-6 outline-none transition-colors"
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.text }}
/>
<div className="rounded-[var(--r-lg)] border px-3 py-2 text-[11px] leading-5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.muted }}>
默认模板:<span style={{ color: theme.node.text }}>{defaultValue}</span>
</div>
</div>
<div className="flex items-center justify-between border-t px-5 py-3" style={{ borderColor: theme.toolbar.border }}>
<button
type="button"
className="flex items-center gap-1.5 text-xs transition-colors hover:opacity-80"
style={{ color: theme.node.muted }}
onClick={() => {
setDraft(defaultValue);
onChange(defaultValue);
}}
>
<RotateCcw className="size-3" />
恢复默认
</button>
<div className="flex items-center gap-2">
<button type="button" className="rounded-[var(--r-md)] px-3 py-1.5 text-xs transition hover:opacity-80" style={{ background: theme.toolbar.itemHover }} onClick={onClose}>关闭</button>
<button type="button" className="rounded-[var(--r-md)] px-3 py-1.5 text-xs font-medium transition hover:opacity-90" style={{ background: theme.node.activeStroke, color: theme.node.panel }} onClick={() => { onChange(draft); onClose(); }}>保存</button>
</div>
</div>
</div>
</div>
);
}
function drawLightSphere(ctx: CanvasRenderingContext2D, width: number, height: number, azimuth: number, elevation: number, previewImageUrl: string | null | undefined, viewMode: "perspective" | "front") {
ctx.clearRect(0, 0, width, height);
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 2 - 4;
// Dark sphere background
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fillStyle = "#111";
ctx.fill();
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.2)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.save();
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.clip();
const azimuthRadians = (azimuth * Math.PI) / 180;
const elevationRadians = (elevation * Math.PI) / 180;
const lightX = viewMode === "front" ? centerX + radius * Math.sin(azimuthRadians) * 0.85 : centerX + radius * Math.sin(azimuthRadians) * Math.cos(elevationRadians);
const lightY = viewMode === "front" ? centerY - radius * Math.sin(elevationRadians) * 0.85 : centerY - radius * Math.sin(elevationRadians);
// Light glow around the light source
const gradientRadius = radius * 1.4;
const gradient = ctx.createRadialGradient(lightX, lightY, 0, lightX, lightY, gradientRadius);
gradient.addColorStop(0, "rgba(255,255,240,0.55)");
gradient.addColorStop(0.35, "rgba(255,230,180,0.18)");
gradient.addColorStop(0.7, "rgba(255,200,100,0.05)");
gradient.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// Grid lines on sphere
if (viewMode === "front") {
for (let index = -2; index <= 2; index++) {
const x = centerX + index * (radius / 3);
ctx.beginPath();
ctx.moveTo(x, centerY - radius);
ctx.lineTo(x, centerY + radius);
ctx.strokeStyle = "rgba(255,255,255,0.07)";
ctx.lineWidth = 0.7;
ctx.stroke();
}
for (let index = -2; index <= 2; index++) {
const y = centerY + index * (radius / 3);
ctx.beginPath();
ctx.moveTo(centerX - radius, y);
ctx.lineTo(centerX + radius, y);
ctx.strokeStyle = "rgba(255,255,255,0.07)";
ctx.lineWidth = 0.7;
ctx.stroke();
}
} else {
for (let latitude = -75; latitude <= 75; latitude += 15) {
const latitudeRadians = (latitude * Math.PI) / 180;
const ellipseRadius = radius * Math.cos(latitudeRadians);
const baseY = centerY - radius * Math.sin(latitudeRadians);
ctx.beginPath();
ctx.ellipse(centerX, baseY, ellipseRadius, ellipseRadius * 0.22, 0, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.07)";
ctx.lineWidth = 0.7;
ctx.stroke();
}
for (let index = 0; index < 8; index++) {
const angle = (index * Math.PI) / 4;
ctx.beginPath();
ctx.ellipse(centerX, centerY, radius * Math.abs(Math.cos(angle)), radius, 0, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.07)";
ctx.lineWidth = 0.7;
ctx.stroke();
}
}
// Draw preview image as a rectangular subject in center
if (previewImageUrl) {
const image = new Image();
image.onload = () => {
const imageWidth = radius * 0.5;
const imageHeight = radius * 0.75;
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.8)";
ctx.shadowBlur = 12;
ctx.drawImage(image, centerX - imageWidth / 2, centerY - imageHeight / 2, imageWidth, imageHeight);
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(255,255,255,0.2)";
ctx.lineWidth = 1;
ctx.strokeRect(centerX - imageWidth / 2, centerY - imageHeight / 2, imageWidth, imageHeight);
ctx.restore();
};
image.src = previewImageUrl;
} else {
const placeholderWidth = radius * 0.42;
const placeholderHeight = radius * 0.65;
ctx.fillStyle = "rgba(255,255,255,0.04)";
ctx.strokeStyle = "rgba(255,255,255,0.15)";
ctx.lineWidth = 1;
ctx.fillRect(centerX - placeholderWidth / 2, centerY - placeholderHeight / 2, placeholderWidth, placeholderHeight);
ctx.strokeRect(centerX - placeholderWidth / 2, centerY - placeholderHeight / 2, placeholderWidth, placeholderHeight);
ctx.fillStyle = "rgba(255,255,255,0.2)";
const headRadius = placeholderWidth * 0.18;
ctx.beginPath();
ctx.arc(centerX, centerY - placeholderHeight / 2 + headRadius + 4, headRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillRect(centerX - placeholderWidth * 0.15, centerY - placeholderHeight / 2 + headRadius * 2 + 6, placeholderWidth * 0.3, placeholderHeight * 0.35);
}
ctx.restore();
// Light source dot + glow
const glowGradient = ctx.createRadialGradient(lightX, lightY, 0, lightX, lightY, 14);
glowGradient.addColorStop(0, "rgba(255,245,200,0.95)");
glowGradient.addColorStop(0.4, "rgba(255,220,120,0.5)");
glowGradient.addColorStop(1, "rgba(255,200,50,0)");
ctx.beginPath();
ctx.fillStyle = glowGradient;
ctx.arc(lightX, lightY, 14, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(lightX, lightY, 5, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255,0.95)";
ctx.fill();
}
function LightingSphereControl({ azimuth, elevation, onAngleChange, previewImageUrl, viewMode = "perspective" }: { azimuth: number; elevation: number; onAngleChange: (azimuth: number, elevation: number) => void; previewImageUrl?: string | null; viewMode?: "perspective" | "front" }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDraggingRef = useRef(false);
const dragStartRef = useRef<{ x: number; y: number; azimuth: number; elevation: number } | null>(null);
const size = 180;
const redraw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
drawLightSphere(ctx, size, size, azimuth, elevation, previewImageUrl, viewMode);
}, [azimuth, elevation, previewImageUrl, viewMode]);
useEffect(() => {
redraw();
}, [redraw]);
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
event.stopPropagation();
isDraggingRef.current = true;
dragStartRef.current = { x: event.clientX, y: event.clientY, azimuth, elevation };
event.currentTarget.setPointerCapture(event.pointerId);
}, [azimuth, elevation]);
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!isDraggingRef.current || !dragStartRef.current) return;
const deltaX = event.clientX - dragStartRef.current.x;
const deltaY = event.clientY - dragStartRef.current.y;
let nextAzimuth = (dragStartRef.current.azimuth + deltaX * 1.5) % 360;
if (nextAzimuth < 0) nextAzimuth += 360;
const nextElevation = Math.max(-90, Math.min(90, dragStartRef.current.elevation - deltaY));
onAngleChange(Math.round(nextAzimuth), Math.round(nextElevation));
}, [onAngleChange]);
const handlePointerUp = useCallback((event: ReactPointerEvent<HTMLCanvasElement>) => {
isDraggingRef.current = false;
dragStartRef.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
}, []);
return (
<canvas
ref={canvasRef}
width={size}
height={size}
aria-label="拖动调整主光源方向"
className="cursor-grab touch-none rounded-[var(--r-lg)] active:cursor-grabbing"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
);
}

View File

@@ -41,6 +41,7 @@ type CanvasNodeToolbarProps = {
onUpscale: (node: CanvasNodeData) => void;
onSuperResolve: (node: CanvasNodeData) => void;
onAngle: (node: CanvasNodeData) => void;
onLighting: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onExtractVideoFrames: (node: CanvasNodeData) => void;
onExtractAudioFromVideo: (node: CanvasNodeData) => void;
@@ -101,6 +102,7 @@ export function CanvasNodeToolbar({
onUpscale,
onSuperResolve,
onAngle,
onLighting,
onViewImage,
onExtractVideoFrames,
onExtractAudioFromVideo,
@@ -217,7 +219,7 @@ export function CanvasNodeToolbar({
}
copyText(prompt, "提示词已复制");
};
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onLighting, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
// 构建 ToolContext——供注册表解析工具
const nodeHoverHandlers = {