diff --git a/web/src/components/canvas/canvas-image-settings-popover.tsx b/web/src/components/canvas/canvas-image-settings-popover.tsx index bc453a6a0..890380cb7 100644 --- a/web/src/components/canvas/canvas-image-settings-popover.tsx +++ b/web/src/components/canvas/canvas-image-settings-popover.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; -import { Settings2 } from "lucide-react"; -import { Button } from "antd"; +import { Camera, Settings2 } from "lucide-react"; +import { Button, Modal } from "antd"; import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel"; +import { CanvasNodeCameraPanel } from "@/components/canvas/canvas-node-camera-dialog"; import { canvasThemes } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities"; +import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library"; import { useThemeStore } from "@/stores/use-theme-store"; import type { AiConfig } from "@/stores/use-config-store"; @@ -19,14 +21,17 @@ type CanvasImageSettingsPopoverProps = { placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight"; autoAdjustOverflow?: boolean; showCount?: boolean; + cameraControl?: CameraControlOptions; + onCameraControlChange?: (options: CameraControlOptions) => void; }; -export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true }: CanvasImageSettingsPopoverProps) { +export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true, cameraControl, onCameraControlChange }: CanvasImageSettingsPopoverProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const buttonRef = useRef(null); const panelRef = useRef(null); const [open, setOpen] = useState(false); const [buttonRect, setButtonRect] = useState(null); + const [cameraOpen, setCameraOpen] = useState(false); const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!; const normalized = normalizeImageValue(profile, config); const summaryParts = [ @@ -67,16 +72,48 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang const panel = open && buttonRect ? : null; - if (!hasSettings) return null; + if (!hasSettings && !onCameraControlChange) return null; + + const cameraEnabled = cameraControl?.enabled === true; return ( <> - - - + {onCameraControlChange && ( + + + )} {panel} + {cameraOpen && onCameraControlChange && ( + setCameraOpen(false)} styles={{ body: { padding: 0 } }}> + setCameraOpen(false)} + onConfirm={(options, _prompt) => { + onCameraControlChange(options); + setCameraOpen(false); + }} + /> + + )} ); } diff --git a/web/src/components/canvas/canvas-node-camera-dialog.tsx b/web/src/components/canvas/canvas-node-camera-dialog.tsx new file mode 100644 index 000000000..f992b6bd2 --- /dev/null +++ b/web/src/components/canvas/canvas-node-camera-dialog.tsx @@ -0,0 +1,467 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ChevronUp, ChevronDown, Camera as CameraIcon, RotateCcw, X } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; + +import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; +import { Tooltip } from "@/components/ui/base/tooltip"; +import { aceternityMotion } from "@/lib/aceternity-motion"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { useCopyText } from "@/hooks/use-copy-text"; +import { useThemeStore } from "@/stores/use-theme-store"; +import { + APERTURES, + APERTURE_META, + CAMERA_PROFILES, + FOCAL_LENGTHS, + FOCAL_LENGTH_META, + LENS_PROFILES, + buildCameraPrompt, + type CameraControlOptions, + type CameraProfile, + type LensProfile, +} from "@/lib/canvas/camera-prompt-library"; + +const defaultCameraControl: CameraControlOptions = { + enabled: true, + camera: "arri_alexa_mini_lf", + lens: "arri_signature_prime", + focalLength: 50, + aperture: 2.8, +}; + +/* ── SVG renderers ── */ + +function CameraBodySvg({ profile, className }: { profile: CameraProfile; className?: string }) { + const body = profile.bodyColor; + const accent = profile.accentColor; + switch (profile.id) { + case "panavision_dxl2": + return ( + + + + + + + + + ); + case "arri_alexa_mini_lf": + return ( + + + + + + + + ); + case "red_komodo_6k": + case "red_v_raptor_8k": { + const is_raptor = profile.id === "red_v_raptor_8k"; + return ( + + + + + + RED + + ); + } + case "sony_venice_2": + case "sony_fx6": + return ( + + + + + + SONY + + ); + case "blackmagic_ursa_12k": + return ( + + + + + + URSA + + ); + case "canon_c500_mk2": + return ( + + + + + Canon + + ); + default: + return ( + + + + + ); + } +} + +function LensBodySvg({ profile, className }: { profile: LensProfile; className?: string }) { + const body = profile.lensColor; + const ring = profile.ringColor; + const rings = profile.id.startsWith("anamorphic") ? 4 : 3; + return ( + + + {Array.from({ length: rings }).map((_, i) => ( + + ))} + + + {profile.id.startsWith("anamorphic") && ( + + )} + + ); +} + +/* ── HoverTip ── */ + +interface HoverTipProps { + title: string; + description?: string; + useCase?: string; + children: React.ReactNode; +} + +const HoverTip = memo(({ title, description, useCase, children }: HoverTipProps) => { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const [coords, setCoords] = useState<{ left: number; top: number; placement: "top" | "bottom" } | null>(null); + const anchorRef = useRef(null); + const tipRef = useRef(null); + const timerRef = useRef(null); + + const computePlacement = useCallback(() => { + const el = anchorRef.current; + if (!el) return null; + const rect = el.getBoundingClientRect(); + const tooltipWidth = 260; + const margin = 10; + let left = rect.left + rect.width / 2 - tooltipWidth / 2; + left = Math.max(margin, Math.min(window.innerWidth - tooltipWidth - margin, left)); + const placement: "top" | "bottom" = "top"; + const top = rect.top - margin; + return { left, top, placement }; + }, []); + + useEffect(() => { + if (!coords) return; + const tip = tipRef.current; + const anchor = anchorRef.current; + if (!tip || !anchor) return; + const tipH = tip.getBoundingClientRect().height; + const rect = anchor.getBoundingClientRect(); + const margin = 10; + let placement = coords.placement; + if (placement === "top" && rect.top - tipH - margin < 0) placement = "bottom"; + if (placement !== coords.placement) { + const top = placement === "top" ? rect.top - margin : rect.bottom + margin; + setCoords((c) => (c ? { ...c, placement, top } : c)); + } + }, [coords]); + + const handleEnter = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + const c = computePlacement(); + if (c) setCoords(c); + }, 900); + }; + const handleLeave = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + setCoords(null); + }; + + return ( +
+ {children} + {coords && + createPortal( +
+
{title}
+ {description &&
{description}
} + {useCase &&
使用场景:{useCase}
} +
, + document.body, + )} +
+ ); +}); +HoverTip.displayName = "HoverTip"; + +/* ── CardColumn ── */ + +interface CardColumnProps { + label: string; + tooltipTitle: string; + tooltipDesc?: string; + tooltipUseCase?: string; + visual: React.ReactNode; + cornerBadge?: React.ReactNode; + captionBelow: string; + onPrev: () => void; + onNext: () => void; + theme: (typeof canvasThemes)[keyof typeof canvasThemes]; +} + +const CardColumn = memo(({ label, tooltipTitle, tooltipDesc, tooltipUseCase, visual, cornerBadge, captionBelow, onPrev, onNext, theme }: CardColumnProps) => { + return ( +
+ + +
+ {label} +
{visual}
+ {cornerBadge && ( +
+ {cornerBadge} +
+ )} +
+
+ + {captionBelow} +
+ ); +}); +CardColumn.displayName = "CardColumn"; + +/* ── Main panel ── */ + +export function CanvasNodeCameraPanel({ + cameraControl, + onClose, + onConfirm, +}: { + cameraControl?: CameraControlOptions; + onClose: () => void; + onConfirm: (options: CameraControlOptions, prompt: string) => void; +}) { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const reducedMotion = useReducedMotion(); + const copyText = useCopyText(); + + const [enabled, setEnabled] = useState(cameraControl?.enabled ?? true); + const [cameraIdx, setCameraIdx] = useState(() => { + const i = CAMERA_PROFILES.findIndex((c) => c.id === cameraControl?.camera); + return i >= 0 ? i : 0; + }); + const [lensIdx, setLensIdx] = useState(() => { + const i = LENS_PROFILES.findIndex((l) => l.id === cameraControl?.lens); + return i >= 0 ? i : 0; + }); + const [focal, setFocal] = useState(cameraControl?.focalLength ?? 50); + const [aperture, setAperture] = useState(cameraControl?.aperture ?? 4); + + const currentCamera = CAMERA_PROFILES[cameraIdx]; + const currentLens = LENS_PROFILES[lensIdx]; + const focalMeta = FOCAL_LENGTH_META[focal]; + const apertureMeta = APERTURE_META[aperture]; + + const summary = useMemo(() => `${currentCamera.zhName} · ${currentLens.zhName} · ${focal}mm · f/${aperture}`, [currentCamera, currentLens, focal, aperture]); + + const cycleCamera = (dir: 1 | -1) => setCameraIdx((i) => (i + dir + CAMERA_PROFILES.length) % CAMERA_PROFILES.length); + const cycleLens = (dir: 1 | -1) => setLensIdx((i) => (i + dir + LENS_PROFILES.length) % LENS_PROFILES.length); + const cycleFocal = (dir: 1 | -1) => setFocal((f) => { + const idx = FOCAL_LENGTHS.indexOf(f as (typeof FOCAL_LENGTHS)[number]); + const nextIdx = (idx + dir + FOCAL_LENGTHS.length) % FOCAL_LENGTHS.length; + return FOCAL_LENGTHS[nextIdx]; + }); + const cycleAperture = (dir: 1 | -1) => setAperture((a) => { + const idx = APERTURES.indexOf(a as (typeof APERTURES)[number]); + const nextIdx = (idx + dir + APERTURES.length) % APERTURES.length; + return APERTURES[nextIdx]; + }); + + const buildPrompt = useCallback(() => { + if (!enabled) return ""; + return buildCameraPrompt({ + cameraId: currentCamera.id, + lensId: currentLens.id, + focalLengthMm: focal, + apertureF: aperture, + }); + }, [enabled, currentCamera, currentLens, focal, aperture]); + + const handleApply = () => { + const options: CameraControlOptions = { enabled, camera: currentCamera.id, lens: currentLens.id, focalLength: focal, aperture }; + onConfirm(options, buildPrompt()); + }; + + const handleReset = () => { + setEnabled(defaultCameraControl.enabled); + setCameraIdx(0); + setLensIdx(0); + setFocal(defaultCameraControl.focalLength); + setAperture(defaultCameraControl.aperture); + }; + + const secondaryButtonClass = "flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-medium transition hover:bg-black/5 dark:hover:bg-white/10"; + + return ( + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + {/* Header */} + + + {/* Card columns */} +
+ } + captionBelow={currentCamera.zhName} + onPrev={() => cycleCamera(-1)} + onNext={() => cycleCamera(1)} + theme={theme} + /> + } + captionBelow={currentLens.zhName} + onPrev={() => cycleLens(-1)} + onNext={() => cycleLens(1)} + theme={theme} + /> + +
{focal}
+
mm
+
+ } + cornerBadge={focalMeta?.zhName} + captionBelow={focalMeta?.zhName ?? ""} + onPrev={() => cycleFocal(-1)} + onNext={() => cycleFocal(1)} + theme={theme} + /> + +
+ f/{aperture} +
+
aperture
+ + } + cornerBadge={apertureMeta?.zhName} + captionBelow={apertureMeta?.zhName ?? ""} + onPrev={() => cycleAperture(-1)} + onNext={() => cycleAperture(1)} + theme={theme} + /> + + + {/* Summary */} +
+
当前配置
+
{summary}
+
+ + {/* Footer */} +
+ + + + + + {/* Enable toggle */} +
+ + {enabled ? "开启" : "关闭"} + + +
+ + 应用 + +
+
+ ); +} diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index fdb6d73df..dad1f45f0 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -380,6 +380,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" ? ( = { + 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 = { + 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 { + 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 cam = CAMERA_PROFILES.find((c) => c.id === input.cameraId) ?? CAMERA_PROFILES[0]; + const lens = LENS_PROFILES.find((l) => l.id === input.lensId) ?? LENS_PROFILES[0]; + return applyTemplate(CAMERA_PROMPT_TEMPLATE, { + cameraProfilePrompt: cam.profilePrompt, + lensProfilePrompt: lens.profilePrompt, + focalLengthPrompt: describeFocalLength(input.focalLengthMm), + aperturePrompt: describeAperture(input.apertureF), + cameraBody: cam.shortTag, + lens: lens.shortTag, + focalLengthMm: String(input.focalLengthMm), + apertureF: String(input.apertureF), + }); +} diff --git a/web/src/pages/canvas/use-canvas-generation-executor.ts b/web/src/pages/canvas/use-canvas-generation-executor.ts index c0375148f..4b25e9b14 100644 --- a/web/src/pages/canvas/use-canvas-generation-executor.ts +++ b/web/src/pages/canvas/use-canvas-generation-executor.ts @@ -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 { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution"; import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error"; import { modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection"; @@ -129,7 +130,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>; diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index dd771b3f6..f80221304 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -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 = {