feat(canvas): 图片编辑 - 合并打光预设与镜头参数控制
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.next/
|
||||
out/
|
||||
node_modules/
|
||||
open-storyboard-canvas/
|
||||
docs/*
|
||||
!docs/design/
|
||||
!docs/index.md
|
||||
|
||||
@@ -175,6 +175,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
- [ ] 一键引用全文保留重复指代,跳过已有引用、重名和“约2秒”等普通文本;无匹配时按钮禁用。
|
||||
- [ ] 长提示词滚动、移动光标、中文输入和切换节点后,引用插入位置正确;素材原有双击预览正常。
|
||||
|
||||
## 图片节点打光与镜头控制
|
||||
|
||||
- 图片节点支持基于原图的打光与镜头控制;确认操作只生成明确的编辑请求,不修改源节点内容。
|
||||
- 打光支持光位、俯仰、亮度、光色、轮廓光和预设;镜头控制由声明式参数构建提示词,生成结果保留对应操作元数据。
|
||||
- [ ] 登录画布后分别执行打光与镜头控制,确认新节点、连线、模型配置、失败状态和窄屏菜单行为正确。
|
||||
- [ ] 检查明暗主题、键盘焦点、弹窗滚动、复制提示词与自定义模板;确认取消操作不会触发生成。
|
||||
|
||||
## v1.2.8.rc1 候选版本发布
|
||||
|
||||
- 发布版本与镜像标签分别为 `v1.2.8.rc1` 和 `1.2.8.rc1`;GitHub Release 应标记为预发布,不能被当作正式版本推荐,也不覆盖已发布的 `latest`。
|
||||
|
||||
BIN
web/public/lighting-presets/blue-backlight.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
web/public/lighting-presets/cyberpunk.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
web/public/lighting-presets/golden-hour.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
web/public/lighting-presets/mysterious.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/public/lighting-presets/nolan-grey.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/public/lighting-presets/overexposed.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
web/public/lighting-presets/rembrandt.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
web/public/lighting-presets/sunset.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
@@ -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<HTMLSpanElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(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 ? <ImageSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} showCount={showCount} onConfigChange={onConfigChange} /> : null;
|
||||
|
||||
if (!hasSettings) return null;
|
||||
if (!hasSettings && !onCameraControlChange) return null;
|
||||
|
||||
const cameraEnabled = cameraControl?.enabled === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
||||
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`图像设置:${summary}`} title={`图像设置 · ${summary}`} onClick={() => updateOpen(!open)}>
|
||||
<span className="truncate">{summary}</span>
|
||||
</Button>
|
||||
</span>
|
||||
{onCameraControlChange && (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="canvas-camera-control-trigger !h-8 !rounded-full !px-2.5"
|
||||
style={{
|
||||
background: cameraEnabled ? theme.node.activeStroke : theme.node.fill,
|
||||
color: cameraEnabled ? theme.node.panel : theme.node.text,
|
||||
}}
|
||||
icon={<Camera className="size-3.5" />}
|
||||
aria-pressed={cameraEnabled}
|
||||
aria-label="摄像机控制"
|
||||
title={`摄像机控制${cameraEnabled ? " · 已启用" : ""}`}
|
||||
onClick={() => setCameraOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{hasSettings && (
|
||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
||||
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`图像设置:${summary}`} title={`图像设置 · ${summary}`} onClick={() => updateOpen(!open)}>
|
||||
<span className="truncate">{summary}</span>
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
{panel}
|
||||
{cameraOpen && onCameraControlChange && (
|
||||
<Modal title="摄像机控制" open centered footer={null} width={780} destroyOnHidden onCancel={() => setCameraOpen(false)} styles={{ body: { padding: 0 } }}>
|
||||
<CanvasNodeCameraPanel
|
||||
cameraControl={cameraControl}
|
||||
onClose={() => setCameraOpen(false)}
|
||||
onConfirm={(options, _prompt) => {
|
||||
onCameraControlChange(options);
|
||||
setCameraOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "预览",
|
||||
|
||||
467
web/src/components/canvas/canvas-node-camera-dialog.tsx
Normal file
@@ -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 (
|
||||
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<rect x="18" y="8" width="30" height="32" rx="3" fill={body} stroke={accent} strokeWidth="1.2" />
|
||||
<rect x="10" y="12" width="12" height="24" rx="2" fill="#1b1b1b" stroke={accent} />
|
||||
<rect x="48" y="14" width="16" height="20" rx="2" fill="#222" stroke={accent} />
|
||||
<circle cx="56" cy="24" r="6" fill="#0a0a0a" stroke={accent} />
|
||||
<rect x="26" y="3" width="14" height="6" rx="1" fill={body} />
|
||||
<rect x="20" y="42" width="26" height="4" rx="1" fill={accent} opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
case "arri_alexa_mini_lf":
|
||||
return (
|
||||
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<rect x="14" y="12" width="38" height="28" rx="4" fill={body} stroke={accent} />
|
||||
<circle cx="52" cy="26" r="10" fill="#111" stroke={accent} strokeWidth="1.2" />
|
||||
<circle cx="52" cy="26" r="6" fill="#2b2b2b" stroke="#666" />
|
||||
<rect x="20" y="6" width="18" height="8" rx="1.5" fill={body} />
|
||||
<rect x="18" y="18" width="8" height="6" rx="1" fill={accent} opacity="0.3" />
|
||||
</svg>
|
||||
);
|
||||
case "red_komodo_6k":
|
||||
case "red_v_raptor_8k": {
|
||||
const is_raptor = profile.id === "red_v_raptor_8k";
|
||||
return (
|
||||
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<rect x="18" y="14" width={is_raptor ? "32" : "28"} height="26" rx="2" fill={body} stroke={accent} />
|
||||
<circle cx={is_raptor ? "50" : "46"} cy="27" r="9" fill="#050505" stroke={accent} />
|
||||
<circle cx={is_raptor ? "50" : "46"} cy="27" r="5" fill="#1a1a1a" stroke="#666" />
|
||||
<rect x="20" y="8" width="8" height="7" rx="1" fill={accent} opacity="0.85" />
|
||||
<text x="22" y="36" fontSize="6" fill="#fff" fontWeight="bold">RED</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
case "sony_venice_2":
|
||||
case "sony_fx6":
|
||||
return (
|
||||
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<rect x="14" y="14" width="36" height="26" rx="3" fill={body} stroke={accent} />
|
||||
<circle cx="52" cy="27" r="9" fill="#050505" stroke={accent} />
|
||||
<rect x="16" y="18" width="6" height="6" rx="1" fill={accent} opacity="0.5" />
|
||||
<rect x="22" y="8" width="16" height="8" rx="1.5" fill={body} />
|
||||
<text x="22" y="36" fontSize="5.5" fill="#ccc">SONY</text>
|
||||
</svg>
|
||||
);
|
||||
case "blackmagic_ursa_12k":
|
||||
return (
|
||||
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<rect x="16" y="12" width="32" height="28" rx="2" fill={body} stroke={accent} />
|
||||
<circle cx="50" cy="26" r="10" fill="#050505" stroke={accent} />
|
||||
<circle cx="50" cy="26" r="6" fill="#1a1a1a" stroke="#666" />
|
||||
<rect x="24" y="5" width="12" height="8" rx="1" fill={body} />
|
||||
<text x="18" y="35" fontSize="5" fill={accent} fontWeight="bold">URSA</text>
|
||||
</svg>
|
||||
);
|
||||
case "canon_c500_mk2":
|
||||
return (
|
||||
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<rect x="16" y="12" width="34" height="28" rx="3" fill={body} stroke={accent} />
|
||||
<circle cx="50" cy="26" r="9" fill="#050505" stroke={accent} />
|
||||
<rect x="18" y="16" width="10" height="5" rx="1" fill={accent} opacity="0.7" />
|
||||
<text x="20" y="36" fontSize="5.5" fill="#eee">Canon</text>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<rect x="16" y="14" width="36" height="26" rx="3" fill={body} stroke={accent} />
|
||||
<circle cx="52" cy="27" r="9" fill="#0a0a0a" stroke={accent} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg viewBox="0 0 72 44" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<rect x="6" y="14" width="56" height="18" rx="3" fill={body} stroke="#444" />
|
||||
{Array.from({ length: rings }).map((_, i) => (
|
||||
<rect key={i} x={12 + i * 12} y="14" width="4" height="18" fill={ring} opacity="0.6" />
|
||||
))}
|
||||
<circle cx="58" cy="23" r="7" fill="#050505" stroke={ring} strokeWidth="1.5" />
|
||||
<circle cx="58" cy="23" r="3.5" fill="#222" stroke="#888" />
|
||||
{profile.id.startsWith("anamorphic") && (
|
||||
<ellipse cx="58" cy="23" rx="7" ry="3" fill="none" stroke={ring} strokeWidth="0.8" opacity="0.7" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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<HTMLDivElement | null>(null);
|
||||
const tipRef = useRef<HTMLDivElement | null>(null);
|
||||
const timerRef = useRef<number | null>(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 (
|
||||
<div ref={anchorRef} className="relative" onMouseEnter={handleEnter} onMouseLeave={handleLeave}>
|
||||
{children}
|
||||
{coords &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
className="pointer-events-none fixed z-[20000] w-[260px] rounded-lg border p-3 shadow-2xl"
|
||||
style={{
|
||||
left: `${coords.left}px`,
|
||||
top: coords.placement === "bottom" ? `${coords.top}px` : undefined,
|
||||
bottom: coords.placement === "top" ? `${window.innerHeight - coords.top}px` : undefined,
|
||||
borderColor: theme.toolbar.border,
|
||||
background: theme.spatial.elevated,
|
||||
color: theme.node.text,
|
||||
}}
|
||||
>
|
||||
<div className="text-[12px] font-semibold">{title}</div>
|
||||
{description && <div className="mt-1 text-[11px] leading-5" style={{ color: theme.node.muted }}>{description}</div>}
|
||||
{useCase && <div className="mt-2 rounded px-2 py-1 text-[10px]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>使用场景:{useCase}</div>}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
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 (
|
||||
<div className="flex flex-1 flex-col items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
className="flex h-5 w-full items-center justify-center rounded-md transition-colors"
|
||||
style={{ color: theme.node.faint }}
|
||||
title="上一项"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
<HoverTip title={tooltipTitle} description={tooltipDesc} useCase={tooltipUseCase}>
|
||||
<div
|
||||
className="relative flex h-[140px] w-[140px] cursor-help flex-col items-center justify-between rounded-xl border px-3 pt-2 pb-2 transition-colors"
|
||||
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover }}
|
||||
>
|
||||
<span className="text-[11px] font-medium tracking-wide" style={{ color: theme.node.muted }}>{label}</span>
|
||||
<div className="flex w-full flex-1 items-center justify-center">{visual}</div>
|
||||
{cornerBadge && (
|
||||
<div className="absolute right-1.5 top-1.5 rounded-md px-1.5 py-0.5 text-[10px] font-medium" style={{ background: "rgba(0,0,0,.5)", color: "rgba(255,255,255,.8)" }}>
|
||||
{cornerBadge}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverTip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className="flex h-5 w-full items-center justify-center rounded-md transition-colors"
|
||||
style={{ color: theme.node.faint }}
|
||||
title="下一项"
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
<span className="max-w-full truncate text-center text-[11px] leading-4" style={{ color: theme.node.muted }}>{captionBelow}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
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 (
|
||||
<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-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()}
|
||||
>
|
||||
{/* 标题区由外层 AppModal 统一提供,面板只负责镜头参数。 */}
|
||||
|
||||
|
||||
{/* 相机、镜头、焦距和光圈均来自已注册枚举,确认时由纯函数再次强校验。 */}
|
||||
<div className="flex justify-center gap-3 px-5 py-5">
|
||||
<CardColumn
|
||||
label="相机"
|
||||
tooltipTitle={`${currentCamera.zhName} · ${currentCamera.label}`}
|
||||
tooltipDesc={currentCamera.description}
|
||||
tooltipUseCase={currentCamera.useCase}
|
||||
visual={<CameraBodySvg profile={currentCamera} className="h-16" />}
|
||||
captionBelow={currentCamera.zhName}
|
||||
onPrev={() => cycleCamera(-1)}
|
||||
onNext={() => cycleCamera(1)}
|
||||
theme={theme}
|
||||
/>
|
||||
<CardColumn
|
||||
label="镜头"
|
||||
tooltipTitle={`${currentLens.zhName} · ${currentLens.label}`}
|
||||
tooltipDesc={currentLens.description}
|
||||
tooltipUseCase={currentLens.useCase}
|
||||
visual={<LensBodySvg profile={currentLens} className="h-14" />}
|
||||
captionBelow={currentLens.zhName}
|
||||
onPrev={() => cycleLens(-1)}
|
||||
onNext={() => cycleLens(1)}
|
||||
theme={theme}
|
||||
/>
|
||||
<CardColumn
|
||||
label="焦距"
|
||||
tooltipTitle={`${focal}mm · ${focalMeta?.zhName ?? ""}`}
|
||||
tooltipDesc={focalMeta?.description}
|
||||
tooltipUseCase={focalMeta?.useCase}
|
||||
visual={
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-[40px] font-light leading-none">{focal}</div>
|
||||
<div className="mt-1 text-[10px] tracking-wider" style={{ color: theme.node.faint }}>mm</div>
|
||||
</div>
|
||||
}
|
||||
cornerBadge={focalMeta?.zhName}
|
||||
captionBelow={focalMeta?.zhName ?? ""}
|
||||
onPrev={() => cycleFocal(-1)}
|
||||
onNext={() => cycleFocal(1)}
|
||||
theme={theme}
|
||||
/>
|
||||
<CardColumn
|
||||
label="光圈"
|
||||
tooltipTitle={`f/${aperture} · ${apertureMeta?.zhName ?? ""}`}
|
||||
tooltipDesc={apertureMeta?.description}
|
||||
tooltipUseCase={apertureMeta?.useCase}
|
||||
visual={
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-[30px] font-light leading-none">
|
||||
<span className="text-[18px]" style={{ color: theme.node.faint }}>f/</span>{aperture}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] tracking-wider" style={{ color: theme.node.faint }}>aperture</div>
|
||||
</div>
|
||||
}
|
||||
cornerBadge={apertureMeta?.zhName}
|
||||
captionBelow={apertureMeta?.zhName ?? ""}
|
||||
onPrev={() => cycleAperture(-1)}
|
||||
onNext={() => cycleAperture(1)}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 当前有效配置摘要。 */}
|
||||
<div className="mx-5 mb-3 rounded-lg border px-3.5 py-2.5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover }}>
|
||||
<div className="text-[10px] uppercase tracking-wide" style={{ color: theme.node.faint }}>当前配置</div>
|
||||
<div className="mt-0.5 text-xs">{summary}</div>
|
||||
</div>
|
||||
|
||||
{/* 操作区:重置与复制不产生写入,只有应用会提交生成配置。 */}
|
||||
<div className="flex h-12 items-center gap-2 border-t px-5" 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>
|
||||
{/* 关闭后仍保留参数选择,但提交空提示词,便于用户无损恢复。 */}
|
||||
<div className="flex items-center gap-2" title={enabled ? "当前启用摄像机控制" : "当前关闭摄像机控制"}>
|
||||
<span className="text-xs transition-colors" style={{ color: enabled ? theme.accent.primary : theme.node.muted, fontWeight: enabled ? 600 : 400 }}>
|
||||
{enabled ? "开启" : "关闭"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => setEnabled((v) => !v)}
|
||||
className="relative inline-flex h-6 w-11 cursor-pointer items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? theme.node.activeStroke : theme.toolbar.itemHover }}
|
||||
>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${enabled ? "translate-x-[22px]" : "translate-x-0.5"}`} />
|
||||
</button>
|
||||
</div>
|
||||
<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-4 text-[var(--fs-label)] font-semibold"
|
||||
style={{ background: theme.node.activeStroke, color: theme.node.panel }}
|
||||
onClick={handleApply}
|
||||
>
|
||||
<CameraIcon className="size-3.5" />应用
|
||||
</motion.button>
|
||||
</div>
|
||||
</SpotlightSurface>
|
||||
);
|
||||
}
|
||||
647
web/src/components/canvas/canvas-node-lighting-dialog.tsx
Normal file
@@ -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<string, string>) {
|
||||
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 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-full 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: 280, 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-8 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-8 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -399,6 +399,8 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
|
||||
cameraControl={node.metadata?.cameraControl}
|
||||
onCameraControlChange={(options) => onConfigChange(node.id, { cameraControl: options })}
|
||||
/>
|
||||
) : mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
315
web/src/lib/canvas/camera-prompt-library.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Professional cinematography prompt library for the Camera Control panel.
|
||||
*
|
||||
* Ported from open-storyboard-canvas, simplified for the web project.
|
||||
* The output prompt is a comma-joinable fragment appended to the user's
|
||||
* generation prompt so the image model treats it as camera direction.
|
||||
*/
|
||||
|
||||
export interface CameraProfile {
|
||||
id: string;
|
||||
label: string;
|
||||
zhName: string;
|
||||
shortTag: string;
|
||||
profilePrompt: string;
|
||||
bodyColor: string;
|
||||
accentColor: string;
|
||||
useCase: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface LensProfile {
|
||||
id: string;
|
||||
label: string;
|
||||
zhName: string;
|
||||
shortTag: string;
|
||||
profilePrompt: string;
|
||||
lensColor: string;
|
||||
ringColor: string;
|
||||
useCase: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CameraControlOptions {
|
||||
enabled: boolean;
|
||||
camera: string;
|
||||
lens: string;
|
||||
focalLength: number;
|
||||
aperture: number;
|
||||
}
|
||||
|
||||
export const CAMERA_PROFILES: CameraProfile[] = [
|
||||
{
|
||||
id: "panavision_dxl2",
|
||||
label: "Panavision DXL2",
|
||||
zhName: "潘那维申 DXL2",
|
||||
shortTag: "Panavision DXL2",
|
||||
profilePrompt: "shot on Panavision DXL2 with Panavision DXL color science, large-format Monstro 8K VV sensor, signature organic highlight rolloff, rich filmic texture, premium modern cinema look",
|
||||
bodyColor: "#2a2a2a",
|
||||
accentColor: "#e2a84f",
|
||||
description: "好莱坞顶级院线电影机,8K 大画幅传感器,质感油润。",
|
||||
useCase: "剧情长片、奢华广告、顶级商业片",
|
||||
},
|
||||
{
|
||||
id: "arri_alexa_mini_lf",
|
||||
label: "ARRI Alexa Mini LF",
|
||||
zhName: "阿莱 Mini LF",
|
||||
shortTag: "ARRI Alexa Mini LF",
|
||||
profilePrompt: "shot on ARRI Alexa Mini LF with ARRI Wide Gamut color science, ALEV III large-format sensor, gorgeous skin-tone rendering, buttery highlight rolloff, industry-standard feature-film look",
|
||||
bodyColor: "#3a3a3a",
|
||||
accentColor: "#d4d4d4",
|
||||
description: "行业基准院线电影机,肤色渲染公认最佳。",
|
||||
useCase: "剧情片、人像叙事、品牌广告",
|
||||
},
|
||||
{
|
||||
id: "red_komodo_6k",
|
||||
label: "RED Komodo 6K",
|
||||
zhName: "RED 科莫多 6K",
|
||||
shortTag: "RED Komodo 6K",
|
||||
profilePrompt: "shot on RED Komodo 6K with RED IPP2 color pipeline, Super35 global-shutter sensor, crisp modern digital detail, clean shadows, crisp action-movie look",
|
||||
bodyColor: "#8b2b2b",
|
||||
accentColor: "#ff3b3b",
|
||||
description: "紧凑全局快门 6K,无果冻效应,动作片首选。",
|
||||
useCase: "动作片、FPV、穿越机、无人机",
|
||||
},
|
||||
{
|
||||
id: "red_v_raptor_8k",
|
||||
label: "RED V-Raptor 8K",
|
||||
zhName: "RED V-Raptor 8K",
|
||||
shortTag: "RED V-Raptor 8K",
|
||||
profilePrompt: "shot on RED V-Raptor 8K VV, RED IPP2 color pipeline, 8K Vista Vision sensor, razor-sharp resolution, vivid yet natural color",
|
||||
bodyColor: "#701f1f",
|
||||
accentColor: "#ff5555",
|
||||
description: "RED 旗舰 8K Vista Vision,极致清晰度。",
|
||||
useCase: "科幻大片、高分辨率 VFX 场景",
|
||||
},
|
||||
{
|
||||
id: "sony_venice_2",
|
||||
label: "Sony Venice 2",
|
||||
zhName: "索尼 Venice 2",
|
||||
shortTag: "Sony Venice 2",
|
||||
profilePrompt: "shot on Sony Venice 2 with Sony S-Gamut3.Cine / S-Log3, dual native ISO full-frame cinema sensor, clean shadows, highly refined modern color science",
|
||||
bodyColor: "#2d3a4d",
|
||||
accentColor: "#4d90d0",
|
||||
description: "全画幅双原生 ISO,弱光表现极佳。",
|
||||
useCase: "高端广告、纪录片、夜景戏剧",
|
||||
},
|
||||
{
|
||||
id: "sony_fx6",
|
||||
label: "Sony FX6",
|
||||
zhName: "索尼 FX6",
|
||||
shortTag: "Sony FX6",
|
||||
profilePrompt: "shot on Sony FX6, Sony Venice-derived color science, full-frame cinema sensor, clean low-light performance, documentary-friendly modern look",
|
||||
bodyColor: "#2d2d36",
|
||||
accentColor: "#ff9000",
|
||||
description: "轻量全画幅 Cinema Line,手持纪录片常用。",
|
||||
useCase: "纪录片、单兵采访、轻量剧情",
|
||||
},
|
||||
{
|
||||
id: "blackmagic_ursa_12k",
|
||||
label: "Blackmagic URSA 12K",
|
||||
zhName: "黑魔法 URSA 12K",
|
||||
shortTag: "Blackmagic URSA 12K",
|
||||
profilePrompt: "shot on Blackmagic URSA Cine 12K with Blackmagic Generation 5 color science, Super35 12K sensor, rich filmic tones, indie-cinema aesthetic",
|
||||
bodyColor: "#2e2e2e",
|
||||
accentColor: "#ffb820",
|
||||
description: "独立电影人宠儿,12K 高分辨率性价比机。",
|
||||
useCase: "独立电影、音乐短片、实验影像",
|
||||
},
|
||||
{
|
||||
id: "canon_c500_mk2",
|
||||
label: "Canon C500 Mk II",
|
||||
zhName: "佳能 C500 Mk II",
|
||||
shortTag: "Canon C500 Mk II",
|
||||
profilePrompt: "shot on Canon C500 Mark II with Canon Cinema Gamut, full-frame cinema sensor, warm natural skin tones, premium broadcast and feature-film character",
|
||||
bodyColor: "#332a22",
|
||||
accentColor: "#e8c070",
|
||||
description: "佳能色彩科学,暖调肤色,广播级电影机。",
|
||||
useCase: "广告、电视剧、婚礼电影",
|
||||
},
|
||||
];
|
||||
|
||||
export const LENS_PROFILES: LensProfile[] = [
|
||||
{
|
||||
id: "arri_signature_prime",
|
||||
label: "ARRI Signature Prime",
|
||||
zhName: "阿莱大师定焦",
|
||||
shortTag: "ARRI Signature Prime",
|
||||
profilePrompt: "ARRI Signature Prime lens, large-format coverage, creamy organic bokeh, gentle contrast, smooth highlight rolloff, soft flattering flares",
|
||||
lensColor: "#2a2a2a",
|
||||
ringColor: "#c4a060",
|
||||
description: "顶级大画幅定焦头,奶油虚化,温润对比。",
|
||||
useCase: "人物叙事、顶级商业片",
|
||||
},
|
||||
{
|
||||
id: "cooke_s7i",
|
||||
label: "Cooke S7/i",
|
||||
zhName: "库克 S7/i",
|
||||
shortTag: "Cooke S7/i",
|
||||
profilePrompt: "Cooke S7/i lens, legendary \"Cooke Look\", warm color bias, gentle contrast, soft rounded bokeh, classic flattering skin rendering",
|
||||
lensColor: "#26231d",
|
||||
ringColor: "#d0a060",
|
||||
description: "传奇「Cooke Look」,暖调偏红,讨喜肤色。",
|
||||
useCase: "古典人像、时代感叙事",
|
||||
},
|
||||
{
|
||||
id: "zeiss_supreme_prime",
|
||||
label: "Zeiss Supreme Prime",
|
||||
zhName: "蔡司至尊定焦",
|
||||
shortTag: "Zeiss Supreme Prime",
|
||||
profilePrompt: "Zeiss Supreme Prime lens, neutral modern color, high micro-contrast, sharp yet refined detail, clean bokeh with subtle cat-eye near edges",
|
||||
lensColor: "#1e1e22",
|
||||
ringColor: "#4d90d0",
|
||||
description: "中性现代色彩,微反差锐利而不硬。",
|
||||
useCase: "科技感广告、现代剧情",
|
||||
},
|
||||
{
|
||||
id: "canon_sumire_prime",
|
||||
label: "Canon Sumire Prime",
|
||||
zhName: "佳能 Sumire 定焦",
|
||||
shortTag: "Canon Sumire Prime",
|
||||
profilePrompt: "Canon Sumire Prime lens, soft organic character, warm gentle color rendering, dreamy rounded bokeh, cinematic softness for portraits",
|
||||
lensColor: "#2c241c",
|
||||
ringColor: "#e0b070",
|
||||
description: "柔和梦幻肤色镜,暖调柔焦。",
|
||||
useCase: "爱情片、梦境、人物特写",
|
||||
},
|
||||
{
|
||||
id: "anamorphic_cooke",
|
||||
label: "Anamorphic (Cooke)",
|
||||
zhName: "Cooke 变形宽银幕",
|
||||
shortTag: "Cooke Anamorphic",
|
||||
profilePrompt: "Cooke anamorphic lens, oval bokeh, horizontal blue streaks for flares, wide cinematic framing, subtle focus roll-off, 2.39:1 anamorphic cinematic feel",
|
||||
lensColor: "#1a1a24",
|
||||
ringColor: "#6a9ed6",
|
||||
description: "宽银幕变形镜,椭圆虚化 + 水平蓝色光晕。",
|
||||
useCase: "科幻大片、太空戏、夜戏",
|
||||
},
|
||||
{
|
||||
id: "anamorphic_atlas",
|
||||
label: "Anamorphic (Atlas)",
|
||||
zhName: "Atlas Orion 变形",
|
||||
shortTag: "Atlas Orion Anamorphic",
|
||||
profilePrompt: "Atlas Orion anamorphic lens, oval bokeh, cyan horizontal flares, modern anamorphic look with clean center and subtle edge blur",
|
||||
lensColor: "#1b2228",
|
||||
ringColor: "#40c0e0",
|
||||
description: "现代变形镜,青色光晕,中心清晰边缘柔。",
|
||||
useCase: "音乐 MV、风格化广告",
|
||||
},
|
||||
{
|
||||
id: "vintage_leica_r",
|
||||
label: "Vintage Leica R",
|
||||
zhName: "徕卡 R 老镜",
|
||||
shortTag: "Vintage Leica R",
|
||||
profilePrompt: "vintage Leica R prime lens, rich color saturation, slight glow on highlights, slightly soft edges, nostalgic 1970s-80s cinema character",
|
||||
lensColor: "#3a2f1f",
|
||||
ringColor: "#d04040",
|
||||
description: "上世纪老镜,高光微透溢,复古怀旧味。",
|
||||
useCase: "年代戏、怀旧 MV、文艺片",
|
||||
},
|
||||
{
|
||||
id: "macro_100mm",
|
||||
label: "Macro 100mm",
|
||||
zhName: "100mm 微距",
|
||||
shortTag: "Macro 100mm",
|
||||
profilePrompt: "macro 100mm prime lens, extremely shallow depth of field, razor-thin focus plane, smooth creamy bokeh, detail-rich close-up rendering",
|
||||
lensColor: "#1f1f1f",
|
||||
ringColor: "#60d090",
|
||||
description: "微距特写专用,细节惊人,景深极浅。",
|
||||
useCase: "美食广告、珠宝、昆虫、细节",
|
||||
},
|
||||
];
|
||||
|
||||
export const FOCAL_LENGTHS = [14, 18, 24, 35, 40, 50, 65, 85, 100, 135, 200] as const;
|
||||
export const APERTURES = [1.2, 1.4, 1.8, 2, 2.8, 4, 5.6, 8, 11, 16] as const;
|
||||
|
||||
export const FOCAL_LENGTH_META: Record<number, { zhName: string; description: string; useCase: string }> = {
|
||||
14: { zhName: "超广角", description: "14mm 极致广角,空间被极度拉伸", useCase: "建筑内景、风景全景、沉浸视角" },
|
||||
18: { zhName: "超广角", description: "18mm 超广角,强烈空间纵深", useCase: "街头摄影、室内、建筑" },
|
||||
24: { zhName: "广角", description: "24mm 广角,环境感强烈", useCase: "风光、大场面、新闻纪实" },
|
||||
35: { zhName: "小广角", description: "35mm 经典叙事焦段,接近人眼", useCase: "纪实、人物环境叙事" },
|
||||
40: { zhName: "准标准", description: "40mm 准标准,构图平衡", useCase: "人像叙事、文艺片" },
|
||||
50: { zhName: "标准", description: "50mm 标准,空间感自然", useCase: "人像、日常、街拍" },
|
||||
65: { zhName: "中焦", description: "65mm 中焦,轻微压缩,讨喜肤色", useCase: "半身人像、对话戏" },
|
||||
85: { zhName: "中长焦", description: "85mm 经典人像焦段", useCase: "特写人像、婚礼" },
|
||||
100: { zhName: "长焦", description: "100mm 长焦,背景压缩明显", useCase: "特写、微距、体育" },
|
||||
135: { zhName: "长焦", description: "135mm 电影专用长焦", useCase: "剧情特写、舞台" },
|
||||
200: { zhName: "超长焦", description: "200mm 超长焦,极致背景压缩", useCase: "体育、野生动物、戏剧感特写" },
|
||||
};
|
||||
|
||||
export const APERTURE_META: Record<number, { zhName: string; description: string; useCase: string }> = {
|
||||
1.2: { zhName: "极大光圈", description: "f/1.2 虚化极致,景深刀削般薄", useCase: "梦幻人像、高光溢出效果" },
|
||||
1.4: { zhName: "大光圈", description: "f/1.4 强烈虚化,电影感十足", useCase: "夜景人像、暗光" },
|
||||
1.8: { zhName: "大光圈", description: "f/1.8 明显背景虚化", useCase: "半身人像、街头弱光" },
|
||||
2: { zhName: "大光圈", description: "f/2 柔和虚化与充足锐度", useCase: "人像、文艺日常" },
|
||||
2.8: { zhName: "标准大光圈", description: "f/2.8 景深适中、虚化柔和", useCase: "群像、叙事" },
|
||||
4: { zhName: "中光圈", description: "f/4 主体清晰,背景有轻微虚化", useCase: "群像、婚礼、电视剧" },
|
||||
5.6: { zhName: "中光圈", description: "f/5.6 背景可辨,环境感强", useCase: "纪实、日常、小品" },
|
||||
8: { zhName: "小光圈", description: "f/8 画质最佳区,前后皆清晰", useCase: "风光、建筑、全景" },
|
||||
11: { zhName: "小光圈", description: "f/11 超大景深,几乎全清晰", useCase: "大场景、产品摆拍" },
|
||||
16: { zhName: "极小光圈", description: "f/16 星芒出现,需高光源", useCase: "日光风光、星芒特效" },
|
||||
};
|
||||
|
||||
const CAMERA_PROMPT_TEMPLATE = [
|
||||
"the following parameters describe the virtual camera, lens, and exposure used to render this image",
|
||||
"they are camera direction, NOT objects to add inside the frame",
|
||||
"do not add any physical camera, lens, tripod, viewfinder, or photography equipment into the image",
|
||||
"{{cameraProfilePrompt}}, {{lensProfilePrompt}}, {{focalLengthPrompt}}, {{aperturePrompt}}",
|
||||
"keep the subjects, scene, and action unchanged — apply these as optical and sensor characteristics only",
|
||||
"[camera body: {{cameraBody}} · lens: {{lens}} · focal: {{focalLengthMm}}mm · aperture: f/{{apertureF}}]",
|
||||
].join(", ");
|
||||
|
||||
export function describeFocalLength(mm: number): string {
|
||||
if (mm <= 16) return `${mm}mm ultra-wide-angle perspective, strong spatial depth, pronounced edge distortion, immersive wide coverage`;
|
||||
if (mm <= 24) return `${mm}mm wide-angle perspective, exaggerated depth, strong foreground/background separation, environment-forward framing`;
|
||||
if (mm <= 35) return `${mm}mm slight-wide cinematic perspective, natural environmental context, balanced depth, classic story-telling framing`;
|
||||
if (mm <= 50) return `${mm}mm standard/normal perspective, close to natural human eye perspective, neutral spatial compression, flattering portrait framing`;
|
||||
if (mm <= 85) return `${mm}mm short-telephoto portrait perspective, mild background compression, flattering facial rendering, subject isolation`;
|
||||
if (mm <= 135) return `${mm}mm telephoto perspective, moderate compression, strong subject isolation from background, cinematic close-up`;
|
||||
return `${mm}mm long-telephoto perspective, strong background compression, highly isolated subject on compressed flat background, powerful dramatic framing`;
|
||||
}
|
||||
|
||||
export function describeAperture(f: number): string {
|
||||
if (f <= 1.4) return `shot wide open at f/${f}, extremely shallow depth of field, strong creamy bokeh, razor-thin focus plane, dreamy background separation`;
|
||||
if (f <= 2) return `shot at f/${f}, very shallow depth of field, smooth cinematic bokeh, clear subject-from-background separation`;
|
||||
if (f <= 2.8) return `shot at f/${f}, shallow depth of field, soft bokeh, natural subject isolation`;
|
||||
if (f <= 4) return `shot at f/${f}, moderate depth of field, gentle falloff, clean background blur without over-smoothing`;
|
||||
if (f <= 5.6) return `shot at f/${f}, balanced depth of field, background retains context while subject remains clearly in focus`;
|
||||
if (f <= 8) return `shot at f/${f}, wide depth of field, environment and subject both sharp, documentary / landscape style`;
|
||||
return `shot at f/${f}, very wide depth of field, extensive sharpness from foreground to background, wide-open environmental feel`;
|
||||
}
|
||||
|
||||
function applyTemplate(template: string, values: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => values[key] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export interface CameraPromptInput {
|
||||
cameraId: string;
|
||||
lensId: string;
|
||||
focalLengthMm: number;
|
||||
apertureF: number;
|
||||
}
|
||||
|
||||
export function buildCameraPrompt(input: CameraPromptInput): string {
|
||||
// 镜头参数会进入图片生成写路径,未知枚举不能静默退回首项,否则持久化配置与实际生成语义会不一致。
|
||||
const camera = CAMERA_PROFILES.find((item) => item.id === input.cameraId);
|
||||
if (!camera) throw new Error(`不支持的相机配置:${input.cameraId}`);
|
||||
const lens = LENS_PROFILES.find((item) => item.id === input.lensId);
|
||||
if (!lens) throw new Error(`不支持的镜头配置:${input.lensId}`);
|
||||
if (!FOCAL_LENGTHS.includes(input.focalLengthMm as (typeof FOCAL_LENGTHS)[number])) throw new Error(`不支持的焦距:${input.focalLengthMm}mm`);
|
||||
if (!APERTURES.includes(input.apertureF as (typeof APERTURES)[number])) throw new Error(`不支持的光圈:f/${input.apertureF}`);
|
||||
return applyTemplate(CAMERA_PROMPT_TEMPLATE, {
|
||||
cameraProfilePrompt: camera.profilePrompt,
|
||||
lensProfilePrompt: lens.profilePrompt,
|
||||
focalLengthPrompt: describeFocalLength(input.focalLengthMm),
|
||||
aperturePrompt: describeAperture(input.apertureF),
|
||||
cameraBody: camera.shortTag,
|
||||
lens: lens.shortTag,
|
||||
focalLengthMm: String(input.focalLengthMm),
|
||||
apertureF: String(input.apertureF),
|
||||
});
|
||||
}
|
||||
@@ -26,7 +26,8 @@ import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
|
||||
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { App, Button, Modal } from "antd";
|
||||
import { App, Button } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
|
||||
import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel";
|
||||
@@ -48,6 +49,7 @@ import { CanvasVideoSegmentDialog } from "@/components/canvas/canvas-video-segme
|
||||
import { CanvasTimelineDialog } from "@/components/canvas/canvas-timeline-dialog";
|
||||
import { syncNodeSubtitlesToTimeline } from "@/lib/timeline/timeline-build";
|
||||
import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
|
||||
import { CanvasNodeLightingPanel } from "@/components/canvas/canvas-node-lighting-dialog";
|
||||
import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
|
||||
import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
|
||||
import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal";
|
||||
@@ -783,6 +785,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const {
|
||||
angleNodeId,
|
||||
lightingNodeId,
|
||||
emotionNodeId,
|
||||
annotationNodeId,
|
||||
createImageReversePromptNodes,
|
||||
@@ -796,6 +799,7 @@ function InfiniteCanvasPage() {
|
||||
extractingVideoFramesNodeId,
|
||||
frameDialogNodeId,
|
||||
generateAngleNode,
|
||||
generateLightingNode,
|
||||
generateEmotionNode,
|
||||
handleSegmentConfirm,
|
||||
maskEditImageNode,
|
||||
@@ -810,6 +814,7 @@ function InfiniteCanvasPage() {
|
||||
setFrameDialogNodeId,
|
||||
setSegmentDialogNodeId,
|
||||
setAngleNodeId,
|
||||
setLightingNodeId,
|
||||
setEmotionNodeId,
|
||||
setAnnotationNodeId,
|
||||
setCropNodeId,
|
||||
@@ -862,6 +867,7 @@ function InfiniteCanvasPage() {
|
||||
setSplitNodeId(clearDeletedId);
|
||||
setUpscaleNodeId(clearDeletedId);
|
||||
setAngleNodeId(clearDeletedId);
|
||||
setLightingNodeId(clearDeletedId);
|
||||
setEmotionNodeId(clearDeletedId);
|
||||
setSuperResolveNodeId(clearDeletedId);
|
||||
setPreviewNodeId(clearDeletedId);
|
||||
@@ -879,7 +885,7 @@ function InfiniteCanvasPage() {
|
||||
}
|
||||
cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions });
|
||||
},
|
||||
[chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setArtCritiqueNodeId, setCropNodeId, setEmotionNodeId, setFrameDialogNodeId, setMaskEditNodeId, setSegmentDialogNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
|
||||
[chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setArtCritiqueNodeId, setCropNodeId, setEmotionNodeId, setFrameDialogNodeId, setLightingNodeId, setMaskEditNodeId, setSegmentDialogNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -1218,6 +1224,7 @@ function InfiniteCanvasPage() {
|
||||
activeScriptNode,
|
||||
activeStylePresetId,
|
||||
angleNode,
|
||||
lightingNode,
|
||||
emotionNode,
|
||||
annotationNode,
|
||||
batchChildCountById,
|
||||
@@ -1268,6 +1275,7 @@ function InfiniteCanvasPage() {
|
||||
upscaleNodeId,
|
||||
superResolveNodeId,
|
||||
angleNodeId,
|
||||
lightingNodeId,
|
||||
emotionNodeId,
|
||||
previewNodeId,
|
||||
contextMenu,
|
||||
@@ -1521,6 +1529,7 @@ function InfiniteCanvasPage() {
|
||||
setMaskEditNodeId(null);
|
||||
setAnnotationNodeId(null);
|
||||
setAngleNodeId(null);
|
||||
setLightingNodeId(null);
|
||||
setEmotionNodeId(null);
|
||||
setPreviewNodeId(null);
|
||||
setRunningNodeId(null);
|
||||
@@ -2436,13 +2445,13 @@ function InfiniteCanvasPage() {
|
||||
</div>
|
||||
|
||||
{angleNode?.metadata?.content ? (
|
||||
<Modal
|
||||
<AppModal
|
||||
flush
|
||||
open
|
||||
centered
|
||||
title="多角度编辑器"
|
||||
footer={null}
|
||||
width={620}
|
||||
destroyOnHidden
|
||||
onCancel={() => setAngleNodeId(null)}
|
||||
>
|
||||
<CanvasNodeAnglePanel
|
||||
@@ -2452,7 +2461,27 @@ function InfiniteCanvasPage() {
|
||||
void generateAngleNode(angleNode, params);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
) : null}
|
||||
|
||||
{lightingNode?.metadata?.content ? (
|
||||
<AppModal
|
||||
flush
|
||||
open
|
||||
centered
|
||||
title="打光效果"
|
||||
footer={null}
|
||||
width={900}
|
||||
onCancel={() => setLightingNodeId(null)}
|
||||
>
|
||||
<CanvasNodeLightingPanel
|
||||
dataUrl={lightingNode.metadata.content}
|
||||
onClose={() => setLightingNodeId(null)}
|
||||
onConfirm={(options, prompt) => {
|
||||
void generateLightingNode(lightingNode, options, prompt);
|
||||
}}
|
||||
/>
|
||||
</AppModal>
|
||||
) : null}
|
||||
|
||||
{emotionNode?.metadata?.content && !isCanvasNodeMoving ? (
|
||||
@@ -2556,6 +2585,10 @@ function InfiniteCanvasPage() {
|
||||
setDialogNodeId(null);
|
||||
setAngleNodeId((current) => (current === node.id ? null : node.id));
|
||||
}}
|
||||
onLighting={(node) => {
|
||||
setDialogNodeId(null);
|
||||
setLightingNodeId((current) => (current === node.id ? null : node.id));
|
||||
}}
|
||||
onViewImage={(node) => setPreviewNodeId(node.id)}
|
||||
onExtractVideoFrames={openVideoFrameExtractor}
|
||||
onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}
|
||||
|
||||
@@ -275,7 +275,7 @@ export default function SharedCanvasPage() {
|
||||
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
|
||||
</InfiniteCanvas>
|
||||
|
||||
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
|
||||
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
|
||||
|
||||
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onFitContent={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
|
||||
<div className="pointer-events-none absolute bottom-5 right-5 z-[var(--z-panel-floating)] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas
|
||||
import { canvasGenerationPromptMetadata, canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
|
||||
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
|
||||
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import { buildCameraPrompt } from "@/lib/canvas/camera-prompt-library";
|
||||
import { buildTextRewritePrompt } from "@/lib/prompts";
|
||||
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
|
||||
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
|
||||
@@ -130,7 +131,12 @@ export function useCanvasGenerationExecutor({
|
||||
|
||||
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
|
||||
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
|
||||
const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt;
|
||||
let generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt;
|
||||
if (mode === "image" && sourceNode?.metadata?.cameraControl?.enabled) {
|
||||
const cameraControl = sourceNode.metadata.cameraControl;
|
||||
const cameraPrompt = buildCameraPrompt({ cameraId: cameraControl.camera, lensId: cameraControl.lens, focalLengthMm: cameraControl.focalLength, apertureF: cameraControl.aperture });
|
||||
generationPrompt = `${generationPrompt}\n${cameraPrompt}`;
|
||||
}
|
||||
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
|
||||
|
||||
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CanvasImageMaskEditPayload } from "@/components/canvas/canvas-node
|
||||
import type { CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog";
|
||||
import type { CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
|
||||
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
|
||||
import { buildLightingLabel, type CanvasImageLightingOptions } from "@/components/canvas/canvas-node-lighting-dialog";
|
||||
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
|
||||
import type { CanvasVideoFrameParams } from "@/components/canvas/canvas-video-frame-dialog";
|
||||
import type { CanvasVideoSegmentParams } from "@/components/canvas/canvas-video-segment-dialog";
|
||||
@@ -104,6 +105,7 @@ export function useCanvasMediaTools({
|
||||
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
|
||||
const [upscaleNodeId, setUpscaleNodeId] = useState<string | null>(null);
|
||||
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
|
||||
const [lightingNodeId, setLightingNodeId] = useState<string | null>(null);
|
||||
const [emotionNodeId, setEmotionNodeId] = useState<string | null>(null);
|
||||
const [frameDialogNodeId, setFrameDialogNodeId] = useState<string | null>(null);
|
||||
const [extractingVideoFramesNodeId, setExtractingVideoFramesNodeId] = useState<string | null>(null);
|
||||
@@ -780,6 +782,50 @@ export function useCanvasMediaTools({
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
|
||||
|
||||
const generateLightingNode = useCallback(async (node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
|
||||
if (!node.metadata?.content) return;
|
||||
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
|
||||
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return;
|
||||
}
|
||||
const childId = nanoid();
|
||||
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const title = buildLightingLabel(options);
|
||||
const source = nodeReferenceImage(node);
|
||||
if (!source) return;
|
||||
const styleExecution = resolveImageEditStyle(node, prompt, generationConfig);
|
||||
if (!styleExecution) return;
|
||||
const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
|
||||
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
|
||||
setLightingNodeId(null);
|
||||
setRunningNodeId(childId);
|
||||
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: imageSpec.width, height: imageSpec.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata } }]);
|
||||
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
setDialogNodeId(childId);
|
||||
const controller = startGenerationRequest(childId, node.id, childId);
|
||||
try {
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [source], signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "lighting", lighting: options, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
|
||||
const image = result.images?.[0];
|
||||
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, imageSpec.width, imageSpec.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("打光生成节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = generationErrorMessage(error);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
|
||||
} finally {
|
||||
finishGenerationRequest(childId, controller);
|
||||
setRunningNodeId(null);
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
|
||||
|
||||
const generateEmotionNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageEmotionPayload) => {
|
||||
if (!node.metadata?.content) return;
|
||||
const baseConfig = buildGenerationConfig(effectiveConfig, node, "image");
|
||||
@@ -845,6 +891,7 @@ export function useCanvasMediaTools({
|
||||
|
||||
return {
|
||||
angleNodeId,
|
||||
lightingNodeId,
|
||||
emotionNodeId,
|
||||
annotationNodeId,
|
||||
createImageReversePromptNodes,
|
||||
@@ -859,6 +906,7 @@ export function useCanvasMediaTools({
|
||||
frameDialogNodeId,
|
||||
handleSegmentConfirm,
|
||||
generateAngleNode,
|
||||
generateLightingNode,
|
||||
maskEditImageNode,
|
||||
maskEditNodeId,
|
||||
mergeSelectedVideos,
|
||||
@@ -871,6 +919,7 @@ export function useCanvasMediaTools({
|
||||
setFrameDialogNodeId,
|
||||
setSegmentDialogNodeId,
|
||||
setAngleNodeId,
|
||||
setLightingNodeId,
|
||||
generateEmotionNode,
|
||||
setEmotionNodeId,
|
||||
setAnnotationNodeId,
|
||||
|
||||
@@ -35,6 +35,7 @@ type UseCanvasRenderModelOptions = {
|
||||
upscaleNodeId: string | null;
|
||||
superResolveNodeId: string | null;
|
||||
angleNodeId: string | null;
|
||||
lightingNodeId: string | null;
|
||||
emotionNodeId: string | null;
|
||||
previewNodeId: string | null;
|
||||
contextMenu: ContextMenuState | null;
|
||||
@@ -65,6 +66,7 @@ export function useCanvasRenderModel({
|
||||
upscaleNodeId,
|
||||
superResolveNodeId,
|
||||
angleNodeId,
|
||||
lightingNodeId,
|
||||
emotionNodeId,
|
||||
previewNodeId,
|
||||
contextMenu,
|
||||
@@ -225,6 +227,7 @@ export function useCanvasRenderModel({
|
||||
const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null;
|
||||
const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null;
|
||||
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
|
||||
const lightingNode = lightingNodeId ? nodeById.get(lightingNodeId) || null : null;
|
||||
const emotionNode = emotionNodeId ? nodeById.get(emotionNodeId) || null : null;
|
||||
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
|
||||
const contextMenuNode = contextMenu?.type === "node" ? nodeById.get(contextMenu.nodeId) || null : null;
|
||||
@@ -361,6 +364,7 @@ export function useCanvasRenderModel({
|
||||
activeScriptNode,
|
||||
activeStylePresetId,
|
||||
angleNode,
|
||||
lightingNode,
|
||||
emotionNode,
|
||||
annotationNode,
|
||||
batchChildCountById,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-textu
|
||||
import type { StyleExecutionPlan } from "@/lib/canvas/style-profile";
|
||||
import type { PortraitClearanceNodeState } from "@/lib/portrait-clearance/contracts";
|
||||
import type { ArtCritiqueNodeState } from "@/lib/art-critique/contracts";
|
||||
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
|
||||
import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline";
|
||||
|
||||
export type Position = {
|
||||
@@ -429,6 +430,8 @@ export type CanvasNodeMetadata = {
|
||||
portraitClearance?: PortraitClearanceNodeState;
|
||||
/** AI 审美批改节点只保存当前报告和输入指纹,不保存图片二进制。 */
|
||||
artCritique?: ArtCritiqueNodeState;
|
||||
/** 摄像机控制选项,启用后生成时自动追加摄影机/镜头/焦距/光圈提示词。 */
|
||||
cameraControl?: CameraControlOptions;
|
||||
};
|
||||
|
||||
export type CanvasNodeData = {
|
||||
|
||||
31
web/test/camera-prompt-library.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { APERTURES, CAMERA_PROFILES, FOCAL_LENGTHS, LENS_PROFILES, buildCameraPrompt } from "../src/lib/canvas/camera-prompt-library";
|
||||
|
||||
describe("camera prompt contract", () => {
|
||||
test("builds a deterministic prompt from registered camera parameters", () => {
|
||||
const prompt = buildCameraPrompt({
|
||||
cameraId: CAMERA_PROFILES[0].id,
|
||||
lensId: LENS_PROFILES[0].id,
|
||||
focalLengthMm: FOCAL_LENGTHS[0],
|
||||
apertureF: APERTURES[0],
|
||||
});
|
||||
|
||||
expect(prompt).toContain(CAMERA_PROFILES[0].shortTag);
|
||||
expect(prompt).toContain(LENS_PROFILES[0].shortTag);
|
||||
});
|
||||
|
||||
test("rejects unknown persisted parameters instead of silently changing generation semantics", () => {
|
||||
const valid = {
|
||||
cameraId: CAMERA_PROFILES[0].id,
|
||||
lensId: LENS_PROFILES[0].id,
|
||||
focalLengthMm: FOCAL_LENGTHS[0],
|
||||
apertureF: APERTURES[0],
|
||||
};
|
||||
|
||||
expect(() => buildCameraPrompt({ ...valid, cameraId: "unknown-camera" })).toThrow("不支持的相机配置");
|
||||
expect(() => buildCameraPrompt({ ...valid, lensId: "unknown-lens" })).toThrow("不支持的镜头配置");
|
||||
expect(() => buildCameraPrompt({ ...valid, focalLengthMm: 47 })).toThrow("不支持的焦距");
|
||||
expect(() => buildCameraPrompt({ ...valid, apertureF: 3.2 })).toThrow("不支持的光圈");
|
||||
});
|
||||
});
|
||||