diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 5d7656f3c..6e10bc5f0 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 图片节点「打光」工具 + +- 图片节点工具栏「视角」分组新增「打光」工具(在「多角度」右侧):弹出打光面板,可拖拽球体或选择主光源方位/俯仰,调整亮度、光色、轮廓光,选择 8 种打光预设(过曝胶片、蓝色逆光、伦勃朗光、赛博朋克、落日迷幻、神秘暗调、黄金时刻、诺兰冷灰),智能模式支持自然语言描述;支持复制提示词与自定义默认提示词模板。 +- 确认后以参考图编辑模式生成新图片节点(`edit: "lighting"` 元数据),沿用源节点的模型配置与风格执行;打光预设图存放在 `web/public/lighting-presets/`(约 18MB 静态资源)。 +- 面板移植自 `open-storyboard-canvas` 的 LightingControlPanel/LightingSphereControl,按画布主题 token 适配明暗主题;去掉了原面板的模型选择器(沿用节点生成配置),提示词模板为面板内本地状态,不持久化。 +- [ ] 登录画布:图片节点 → 工具栏「打光」,验证明暗主题面板样式、球体拖拽、预设选择、亮度/颜色/轮廓光、复制提示词与模板设置。 +- [ ] 确认后生成新图片节点并连线,失败时节点进入错误态;窄屏(容器 < 640px)下打光与多角度一起收进「图片工具」菜单。 + ## v1.2.8.rc1 候选版本发布 - 发布版本与镜像标签分别为 `v1.2.8.rc1` 和 `1.2.8.rc1`;GitHub Release 应标记为预发布,不能被当作正式版本推荐,也不覆盖已发布的 `latest`。 diff --git a/open-storyboard-canvas b/open-storyboard-canvas new file mode 160000 index 000000000..c610d5895 --- /dev/null +++ b/open-storyboard-canvas @@ -0,0 +1 @@ +Subproject commit c610d5895aab59bf39735c2a2802c3a8b7b124f7 diff --git a/web/public/lighting-presets/blue-backlight.png b/web/public/lighting-presets/blue-backlight.png new file mode 100644 index 000000000..13f7389b7 Binary files /dev/null and b/web/public/lighting-presets/blue-backlight.png differ diff --git a/web/public/lighting-presets/cyberpunk.png b/web/public/lighting-presets/cyberpunk.png new file mode 100644 index 000000000..a5ef8f4b5 Binary files /dev/null and b/web/public/lighting-presets/cyberpunk.png differ diff --git a/web/public/lighting-presets/golden-hour.png b/web/public/lighting-presets/golden-hour.png new file mode 100644 index 000000000..574ed3c18 Binary files /dev/null and b/web/public/lighting-presets/golden-hour.png differ diff --git a/web/public/lighting-presets/mysterious.png b/web/public/lighting-presets/mysterious.png new file mode 100644 index 000000000..66792a4d1 Binary files /dev/null and b/web/public/lighting-presets/mysterious.png differ diff --git a/web/public/lighting-presets/nolan-grey.png b/web/public/lighting-presets/nolan-grey.png new file mode 100644 index 000000000..ab0a33f64 Binary files /dev/null and b/web/public/lighting-presets/nolan-grey.png differ diff --git a/web/public/lighting-presets/overexposed.png b/web/public/lighting-presets/overexposed.png new file mode 100644 index 000000000..523a75b2d Binary files /dev/null and b/web/public/lighting-presets/overexposed.png differ diff --git a/web/public/lighting-presets/rembrandt.png b/web/public/lighting-presets/rembrandt.png new file mode 100644 index 000000000..040894d13 Binary files /dev/null and b/web/public/lighting-presets/rembrandt.png differ diff --git a/web/public/lighting-presets/sunset.png b/web/public/lighting-presets/sunset.png new file mode 100644 index 000000000..1cc71570d Binary files /dev/null and b/web/public/lighting-presets/sunset.png differ diff --git a/web/src/components/canvas/canvas-image-toolbar-tools.tsx b/web/src/components/canvas/canvas-image-toolbar-tools.tsx index 734f2f196..cd5519f13 100644 --- a/web/src/components/canvas/canvas-image-toolbar-tools.tsx +++ b/web/src/components/canvas/canvas-image-toolbar-tools.tsx @@ -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: () => , + group: "viewpoint", + order: 30, + run: (node, handlers) => handlers.onLighting(node), + }, { id: "view", label: "预览", diff --git a/web/src/components/canvas/canvas-node-lighting-dialog.tsx b/web/src/components/canvas/canvas-node-lighting-dialog.tsx new file mode 100644 index 000000000..fb0ecd169 --- /dev/null +++ b/web/src/components/canvas/canvas-node-lighting-dialog.tsx @@ -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) { + 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: 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 ( + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + +
+ {/* Left col: view tabs + light sphere */} +
+
+ {(["perspective", "front"] as const).map((mode) => ( + + ))} +
+ setOptions((current) => ({ ...current, azimuth: azimuthValue, elevation: elevationValue }))} + previewImageUrl={dataUrl} + viewMode={viewMode} + /> +
+ + {/* Middle col: global controls */} +
+
+ 全局 + updateOption("smartMode", checked)} theme={theme} /> +
+ +
+
+ 亮度 +
updateOption("brightness", value)} tooltip={{ formatter: (value) => `${value}%` }} />
+
+ + {options.brightness}% +
+
+
+ +
+ 颜色 +
+ + {options.lightColor === "#ffffff" ? ( +
+
+
+ ) : null} +
+
+ +
+ 主光源 +
+ {LIGHT_POSITIONS.map((position) => ( + + ))} +
+
+ +
+ 轮廓光 + updateOption("rimLight", checked)} theme={theme} /> +
+
+ + {/* Right col: smart description + presets */} +
+ 智能模式 +