feat(canvas): 图片编辑 - 合并打光预设与镜头参数控制

This commit is contained in:
ddCat
2026-09-09 22:55:19 +08:00
24 changed files with 1634 additions and 19 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.next/
out/
node_modules/
open-storyboard-canvas/
docs/*
!docs/design/
!docs/index.md

View File

@@ -175,6 +175,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
- [ ] 一键引用全文保留重复指代,跳过已有引用、重名和“约2秒”等普通文本;无匹配时按钮禁用。
- [ ] 长提示词滚动、移动光标、中文输入和切换节点后,引用插入位置正确;素材原有双击预览正常。
## 图片节点打光与镜头控制
- 图片节点支持基于原图的打光与镜头控制;确认操作只生成明确的编辑请求,不修改源节点内容。
- 打光支持光位、俯仰、亮度、光色、轮廓光和预设;镜头控制由声明式参数构建提示词,生成结果保留对应操作元数据。
- [ ] 登录画布后分别执行打光与镜头控制,确认新节点、连线、模型配置、失败状态和窄屏菜单行为正确。
- [ ] 检查明暗主题、键盘焦点、弹窗滚动、复制提示词与自定义模板;确认取消操作不会触发生成。
## v1.2.8.rc1 候选版本发布
- 发布版本与镜像标签分别为 `v1.2.8.rc1` 和 `1.2.8.rc1`;GitHub Release 应标记为预发布,不能被当作正式版本推荐,也不覆盖已发布的 `latest`。

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -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>
)}
</>
);
}

View File

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

View File

@@ -0,0 +1,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>
);
}

View 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}
/>
);
}

View File

@@ -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

View File

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

View 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),
});
}

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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>>;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 = {

View 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("不支持的光圈");
});
});