diff --git a/.gitignore b/.gitignore index f688cafc7..c7a81ab7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .next/ out/ node_modules/ +open-storyboard-canvas/ docs/* !docs/design/ !docs/index.md diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f3d4b20f9..b00554948 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -175,6 +175,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] 一键引用全文保留重复指代,跳过已有引用、重名和“约2秒”等普通文本;无匹配时按钮禁用。 - [ ] 长提示词滚动、移动光标、中文输入和切换节点后,引用插入位置正确;素材原有双击预览正常。 +## 图片节点打光与镜头控制 + +- 图片节点支持基于原图的打光与镜头控制;确认操作只生成明确的编辑请求,不修改源节点内容。 +- 打光支持光位、俯仰、亮度、光色、轮廓光和预设;镜头控制由声明式参数构建提示词,生成结果保留对应操作元数据。 +- [ ] 登录画布后分别执行打光与镜头控制,确认新节点、连线、模型配置、失败状态和窄屏菜单行为正确。 +- [ ] 检查明暗主题、键盘焦点、弹窗滚动、复制提示词与自定义模板;确认取消操作不会触发生成。 + ## v1.2.8.rc1 候选版本发布 - 发布版本与镜像标签分别为 `v1.2.8.rc1` 和 `1.2.8.rc1`;GitHub Release 应标记为预发布,不能被当作正式版本推荐,也不覆盖已发布的 `latest`。 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-settings-popover.tsx b/web/src/components/canvas/canvas-image-settings-popover.tsx index bc453a6a0..890380cb7 100644 --- a/web/src/components/canvas/canvas-image-settings-popover.tsx +++ b/web/src/components/canvas/canvas-image-settings-popover.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; -import { Settings2 } from "lucide-react"; -import { Button } from "antd"; +import { Camera, Settings2 } from "lucide-react"; +import { Button, Modal } from "antd"; import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel"; +import { CanvasNodeCameraPanel } from "@/components/canvas/canvas-node-camera-dialog"; import { canvasThemes } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities"; +import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library"; import { useThemeStore } from "@/stores/use-theme-store"; import type { AiConfig } from "@/stores/use-config-store"; @@ -19,14 +21,17 @@ type CanvasImageSettingsPopoverProps = { placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight"; autoAdjustOverflow?: boolean; showCount?: boolean; + cameraControl?: CameraControlOptions; + onCameraControlChange?: (options: CameraControlOptions) => void; }; -export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true }: CanvasImageSettingsPopoverProps) { +export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true, cameraControl, onCameraControlChange }: CanvasImageSettingsPopoverProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const buttonRef = useRef(null); const panelRef = useRef(null); const [open, setOpen] = useState(false); const [buttonRect, setButtonRect] = useState(null); + const [cameraOpen, setCameraOpen] = useState(false); const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!; const normalized = normalizeImageValue(profile, config); const summaryParts = [ @@ -67,16 +72,48 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang const panel = open && buttonRect ? : null; - if (!hasSettings) return null; + if (!hasSettings && !onCameraControlChange) return null; + + const cameraEnabled = cameraControl?.enabled === true; return ( <> - - - + {onCameraControlChange && ( + + + )} {panel} + {cameraOpen && onCameraControlChange && ( + setCameraOpen(false)} styles={{ body: { padding: 0 } }}> + setCameraOpen(false)} + onConfirm={(options, _prompt) => { + onCameraControlChange(options); + setCameraOpen(false); + }} + /> + + )} ); } 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-camera-dialog.tsx b/web/src/components/canvas/canvas-node-camera-dialog.tsx new file mode 100644 index 000000000..c5b491468 --- /dev/null +++ b/web/src/components/canvas/canvas-node-camera-dialog.tsx @@ -0,0 +1,467 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ChevronUp, ChevronDown, Camera as CameraIcon, RotateCcw, X } from "lucide-react"; +import { motion, useReducedMotion } from "motion/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"; +import { + APERTURES, + APERTURE_META, + CAMERA_PROFILES, + FOCAL_LENGTHS, + FOCAL_LENGTH_META, + LENS_PROFILES, + buildCameraPrompt, + type CameraControlOptions, + type CameraProfile, + type LensProfile, +} from "@/lib/canvas/camera-prompt-library"; + +const defaultCameraControl: CameraControlOptions = { + enabled: true, + camera: "arri_alexa_mini_lf", + lens: "arri_signature_prime", + focalLength: 50, + aperture: 2.8, +}; + +/* ── SVG renderers ── */ + +function CameraBodySvg({ profile, className }: { profile: CameraProfile; className?: string }) { + const body = profile.bodyColor; + const accent = profile.accentColor; + switch (profile.id) { + case "panavision_dxl2": + return ( + + + + + + + + + ); + case "arri_alexa_mini_lf": + return ( + + + + + + + + ); + case "red_komodo_6k": + case "red_v_raptor_8k": { + const is_raptor = profile.id === "red_v_raptor_8k"; + return ( + + + + + + RED + + ); + } + case "sony_venice_2": + case "sony_fx6": + return ( + + + + + + SONY + + ); + case "blackmagic_ursa_12k": + return ( + + + + + + URSA + + ); + case "canon_c500_mk2": + return ( + + + + + Canon + + ); + default: + return ( + + + + + ); + } +} + +function LensBodySvg({ profile, className }: { profile: LensProfile; className?: string }) { + const body = profile.lensColor; + const ring = profile.ringColor; + const rings = profile.id.startsWith("anamorphic") ? 4 : 3; + return ( + + + {Array.from({ length: rings }).map((_, i) => ( + + ))} + + + {profile.id.startsWith("anamorphic") && ( + + )} + + ); +} + +/* ── HoverTip ── */ + +interface HoverTipProps { + title: string; + description?: string; + useCase?: string; + children: React.ReactNode; +} + +const HoverTip = memo(({ title, description, useCase, children }: HoverTipProps) => { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const [coords, setCoords] = useState<{ left: number; top: number; placement: "top" | "bottom" } | null>(null); + const anchorRef = useRef(null); + const tipRef = useRef(null); + const timerRef = useRef(null); + + const computePlacement = useCallback(() => { + const el = anchorRef.current; + if (!el) return null; + const rect = el.getBoundingClientRect(); + const tooltipWidth = 260; + const margin = 10; + let left = rect.left + rect.width / 2 - tooltipWidth / 2; + left = Math.max(margin, Math.min(window.innerWidth - tooltipWidth - margin, left)); + const placement: "top" | "bottom" = "top"; + const top = rect.top - margin; + return { left, top, placement }; + }, []); + + useEffect(() => { + if (!coords) return; + const tip = tipRef.current; + const anchor = anchorRef.current; + if (!tip || !anchor) return; + const tipH = tip.getBoundingClientRect().height; + const rect = anchor.getBoundingClientRect(); + const margin = 10; + let placement = coords.placement; + if (placement === "top" && rect.top - tipH - margin < 0) placement = "bottom"; + if (placement !== coords.placement) { + const top = placement === "top" ? rect.top - margin : rect.bottom + margin; + setCoords((c) => (c ? { ...c, placement, top } : c)); + } + }, [coords]); + + const handleEnter = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + const c = computePlacement(); + if (c) setCoords(c); + }, 900); + }; + const handleLeave = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + setCoords(null); + }; + + return ( +
+ {children} + {coords && + createPortal( +
+
{title}
+ {description &&
{description}
} + {useCase &&
使用场景:{useCase}
} +
, + document.body, + )} +
+ ); +}); +HoverTip.displayName = "HoverTip"; + +/* ── CardColumn ── */ + +interface CardColumnProps { + label: string; + tooltipTitle: string; + tooltipDesc?: string; + tooltipUseCase?: string; + visual: React.ReactNode; + cornerBadge?: React.ReactNode; + captionBelow: string; + onPrev: () => void; + onNext: () => void; + theme: (typeof canvasThemes)[keyof typeof canvasThemes]; +} + +const CardColumn = memo(({ label, tooltipTitle, tooltipDesc, tooltipUseCase, visual, cornerBadge, captionBelow, onPrev, onNext, theme }: CardColumnProps) => { + return ( +
+ + +
+ {label} +
{visual}
+ {cornerBadge && ( +
+ {cornerBadge} +
+ )} +
+
+ + {captionBelow} +
+ ); +}); +CardColumn.displayName = "CardColumn"; + +/* ── Main panel ── */ + +export function CanvasNodeCameraPanel({ + cameraControl, + onClose, + onConfirm, +}: { + cameraControl?: CameraControlOptions; + onClose: () => void; + onConfirm: (options: CameraControlOptions, prompt: string) => void; +}) { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const reducedMotion = useReducedMotion(); + const copyText = useCopyText(); + + const [enabled, setEnabled] = useState(cameraControl?.enabled ?? true); + const [cameraIdx, setCameraIdx] = useState(() => { + const i = CAMERA_PROFILES.findIndex((c) => c.id === cameraControl?.camera); + return i >= 0 ? i : 0; + }); + const [lensIdx, setLensIdx] = useState(() => { + const i = LENS_PROFILES.findIndex((l) => l.id === cameraControl?.lens); + return i >= 0 ? i : 0; + }); + const [focal, setFocal] = useState(cameraControl?.focalLength ?? 50); + const [aperture, setAperture] = useState(cameraControl?.aperture ?? 4); + + const currentCamera = CAMERA_PROFILES[cameraIdx]; + const currentLens = LENS_PROFILES[lensIdx]; + const focalMeta = FOCAL_LENGTH_META[focal]; + const apertureMeta = APERTURE_META[aperture]; + + const summary = useMemo(() => `${currentCamera.zhName} · ${currentLens.zhName} · ${focal}mm · f/${aperture}`, [currentCamera, currentLens, focal, aperture]); + + const cycleCamera = (dir: 1 | -1) => setCameraIdx((i) => (i + dir + CAMERA_PROFILES.length) % CAMERA_PROFILES.length); + const cycleLens = (dir: 1 | -1) => setLensIdx((i) => (i + dir + LENS_PROFILES.length) % LENS_PROFILES.length); + const cycleFocal = (dir: 1 | -1) => setFocal((f) => { + const idx = FOCAL_LENGTHS.indexOf(f as (typeof FOCAL_LENGTHS)[number]); + const nextIdx = (idx + dir + FOCAL_LENGTHS.length) % FOCAL_LENGTHS.length; + return FOCAL_LENGTHS[nextIdx]; + }); + const cycleAperture = (dir: 1 | -1) => setAperture((a) => { + const idx = APERTURES.indexOf(a as (typeof APERTURES)[number]); + const nextIdx = (idx + dir + APERTURES.length) % APERTURES.length; + return APERTURES[nextIdx]; + }); + + const buildPrompt = useCallback(() => { + if (!enabled) return ""; + return buildCameraPrompt({ + cameraId: currentCamera.id, + lensId: currentLens.id, + focalLengthMm: focal, + apertureF: aperture, + }); + }, [enabled, currentCamera, currentLens, focal, aperture]); + + const handleApply = () => { + const options: CameraControlOptions = { enabled, camera: currentCamera.id, lens: currentLens.id, focalLength: focal, aperture }; + onConfirm(options, buildPrompt()); + }; + + const handleReset = () => { + setEnabled(defaultCameraControl.enabled); + setCameraIdx(0); + setLensIdx(0); + setFocal(defaultCameraControl.focalLength); + setAperture(defaultCameraControl.aperture); + }; + + 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()} + > + {/* 标题区由外层 AppModal 统一提供,面板只负责镜头参数。 */} + + + {/* 相机、镜头、焦距和光圈均来自已注册枚举,确认时由纯函数再次强校验。 */} +
+ } + captionBelow={currentCamera.zhName} + onPrev={() => cycleCamera(-1)} + onNext={() => cycleCamera(1)} + theme={theme} + /> + } + captionBelow={currentLens.zhName} + onPrev={() => cycleLens(-1)} + onNext={() => cycleLens(1)} + theme={theme} + /> + +
{focal}
+
mm
+
+ } + cornerBadge={focalMeta?.zhName} + captionBelow={focalMeta?.zhName ?? ""} + onPrev={() => cycleFocal(-1)} + onNext={() => cycleFocal(1)} + theme={theme} + /> + +
+ f/{aperture} +
+
aperture
+ + } + cornerBadge={apertureMeta?.zhName} + captionBelow={apertureMeta?.zhName ?? ""} + onPrev={() => cycleAperture(-1)} + onNext={() => cycleAperture(1)} + theme={theme} + /> + + + {/* 当前有效配置摘要。 */} +
+
当前配置
+
{summary}
+
+ + {/* 操作区:重置与复制不产生写入,只有应用会提交生成配置。 */} +
+ + + + + + {/* 关闭后仍保留参数选择,但提交空提示词,便于用户无损恢复。 */} +
+ + {enabled ? "开启" : "关闭"} + + +
+ + 应用 + +
+
+ ); +} 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..71f93612e --- /dev/null +++ b/web/src/components/canvas/canvas-node-lighting-dialog.tsx @@ -0,0 +1,647 @@ +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(", "); +} + +// 将方位角与俯仰角转换成模型更稳定的自然语言;禁止描述实体灯具,避免模型把摄影设备画进画面。 +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 */} +
+ 智能模式 +