feat(canvas): add camera control prompt generation feature
新增摄像机控制功能,允许用户为画布节点配置相机、镜头、焦距和光圈参数,生成时自动追加对应的摄影提示词到生成prompt中: 1. 新增相机参数类型定义与画布元数据存储支持 2. 实现相机/镜头参数库与提示词构建逻辑 3. 添加可视化配置弹窗与面板组件 4. 集成到图像生成流程中自动拼接摄影提示词
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Settings2 } from "lucide-react";
|
import { Camera, Settings2 } from "lucide-react";
|
||||||
import { Button } from "antd";
|
import { Button, Modal } from "antd";
|
||||||
|
|
||||||
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
|
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 { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
|
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
|
||||||
|
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import type { AiConfig } from "@/stores/use-config-store";
|
import type { AiConfig } from "@/stores/use-config-store";
|
||||||
|
|
||||||
@@ -19,14 +21,17 @@ type CanvasImageSettingsPopoverProps = {
|
|||||||
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||||
autoAdjustOverflow?: boolean;
|
autoAdjustOverflow?: boolean;
|
||||||
showCount?: 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 theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const buttonRef = useRef<HTMLSpanElement>(null);
|
const buttonRef = useRef<HTMLSpanElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
||||||
|
const [cameraOpen, setCameraOpen] = useState(false);
|
||||||
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!;
|
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!;
|
||||||
const normalized = normalizeImageValue(profile, config);
|
const normalized = normalizeImageValue(profile, config);
|
||||||
const summaryParts = [
|
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;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
{onCameraControlChange && (
|
||||||
<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)}>
|
<Button
|
||||||
<span className="truncate">{summary}</span>
|
size="small"
|
||||||
</Button>
|
type="text"
|
||||||
</span>
|
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}
|
{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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
467
web/src/components/canvas/canvas-node-camera-dialog.tsx
Normal file
467
web/src/components/canvas/canvas-node-camera-dialog.tsx
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { ChevronUp, ChevronDown, Camera as CameraIcon, RotateCcw, X } from "lucide-react";
|
||||||
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
|
|
||||||
|
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
|
||||||
|
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||||
|
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||||
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import { useCopyText } from "@/hooks/use-copy-text";
|
||||||
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import {
|
||||||
|
APERTURES,
|
||||||
|
APERTURE_META,
|
||||||
|
CAMERA_PROFILES,
|
||||||
|
FOCAL_LENGTHS,
|
||||||
|
FOCAL_LENGTH_META,
|
||||||
|
LENS_PROFILES,
|
||||||
|
buildCameraPrompt,
|
||||||
|
type CameraControlOptions,
|
||||||
|
type CameraProfile,
|
||||||
|
type LensProfile,
|
||||||
|
} from "@/lib/canvas/camera-prompt-library";
|
||||||
|
|
||||||
|
const defaultCameraControl: CameraControlOptions = {
|
||||||
|
enabled: true,
|
||||||
|
camera: "arri_alexa_mini_lf",
|
||||||
|
lens: "arri_signature_prime",
|
||||||
|
focalLength: 50,
|
||||||
|
aperture: 2.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── SVG renderers ── */
|
||||||
|
|
||||||
|
function CameraBodySvg({ profile, className }: { profile: CameraProfile; className?: string }) {
|
||||||
|
const body = profile.bodyColor;
|
||||||
|
const accent = profile.accentColor;
|
||||||
|
switch (profile.id) {
|
||||||
|
case "panavision_dxl2":
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||||
|
<rect x="18" y="8" width="30" height="32" rx="3" fill={body} stroke={accent} strokeWidth="1.2" />
|
||||||
|
<rect x="10" y="12" width="12" height="24" rx="2" fill="#1b1b1b" stroke={accent} />
|
||||||
|
<rect x="48" y="14" width="16" height="20" rx="2" fill="#222" stroke={accent} />
|
||||||
|
<circle cx="56" cy="24" r="6" fill="#0a0a0a" stroke={accent} />
|
||||||
|
<rect x="26" y="3" width="14" height="6" rx="1" fill={body} />
|
||||||
|
<rect x="20" y="42" width="26" height="4" rx="1" fill={accent} opacity="0.6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "arri_alexa_mini_lf":
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||||
|
<rect x="14" y="12" width="38" height="28" rx="4" fill={body} stroke={accent} />
|
||||||
|
<circle cx="52" cy="26" r="10" fill="#111" stroke={accent} strokeWidth="1.2" />
|
||||||
|
<circle cx="52" cy="26" r="6" fill="#2b2b2b" stroke="#666" />
|
||||||
|
<rect x="20" y="6" width="18" height="8" rx="1.5" fill={body} />
|
||||||
|
<rect x="18" y="18" width="8" height="6" rx="1" fill={accent} opacity="0.3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "red_komodo_6k":
|
||||||
|
case "red_v_raptor_8k": {
|
||||||
|
const is_raptor = profile.id === "red_v_raptor_8k";
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||||
|
<rect x="18" y="14" width={is_raptor ? "32" : "28"} height="26" rx="2" fill={body} stroke={accent} />
|
||||||
|
<circle cx={is_raptor ? "50" : "46"} cy="27" r="9" fill="#050505" stroke={accent} />
|
||||||
|
<circle cx={is_raptor ? "50" : "46"} cy="27" r="5" fill="#1a1a1a" stroke="#666" />
|
||||||
|
<rect x="20" y="8" width="8" height="7" rx="1" fill={accent} opacity="0.85" />
|
||||||
|
<text x="22" y="36" fontSize="6" fill="#fff" fontWeight="bold">RED</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "sony_venice_2":
|
||||||
|
case "sony_fx6":
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||||
|
<rect x="14" y="14" width="36" height="26" rx="3" fill={body} stroke={accent} />
|
||||||
|
<circle cx="52" cy="27" r="9" fill="#050505" stroke={accent} />
|
||||||
|
<rect x="16" y="18" width="6" height="6" rx="1" fill={accent} opacity="0.5" />
|
||||||
|
<rect x="22" y="8" width="16" height="8" rx="1.5" fill={body} />
|
||||||
|
<text x="22" y="36" fontSize="5.5" fill="#ccc">SONY</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "blackmagic_ursa_12k":
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||||
|
<rect x="16" y="12" width="32" height="28" rx="2" fill={body} stroke={accent} />
|
||||||
|
<circle cx="50" cy="26" r="10" fill="#050505" stroke={accent} />
|
||||||
|
<circle cx="50" cy="26" r="6" fill="#1a1a1a" stroke="#666" />
|
||||||
|
<rect x="24" y="5" width="12" height="8" rx="1" fill={body} />
|
||||||
|
<text x="18" y="35" fontSize="5" fill={accent} fontWeight="bold">URSA</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "canon_c500_mk2":
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||||
|
<rect x="16" y="12" width="34" height="28" rx="3" fill={body} stroke={accent} />
|
||||||
|
<circle cx="50" cy="26" r="9" fill="#050505" stroke={accent} />
|
||||||
|
<rect x="18" y="16" width="10" height="5" rx="1" fill={accent} opacity="0.7" />
|
||||||
|
<text x="20" y="36" fontSize="5.5" fill="#eee">Canon</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||||
|
<rect x="16" y="14" width="36" height="26" rx="3" fill={body} stroke={accent} />
|
||||||
|
<circle cx="52" cy="27" r="9" fill="#0a0a0a" stroke={accent} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function LensBodySvg({ profile, className }: { profile: LensProfile; className?: string }) {
|
||||||
|
const body = profile.lensColor;
|
||||||
|
const ring = profile.ringColor;
|
||||||
|
const rings = profile.id.startsWith("anamorphic") ? 4 : 3;
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 44" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||||
|
<rect x="6" y="14" width="56" height="18" rx="3" fill={body} stroke="#444" />
|
||||||
|
{Array.from({ length: rings }).map((_, i) => (
|
||||||
|
<rect key={i} x={12 + i * 12} y="14" width="4" height="18" fill={ring} opacity="0.6" />
|
||||||
|
))}
|
||||||
|
<circle cx="58" cy="23" r="7" fill="#050505" stroke={ring} strokeWidth="1.5" />
|
||||||
|
<circle cx="58" cy="23" r="3.5" fill="#222" stroke="#888" />
|
||||||
|
{profile.id.startsWith("anamorphic") && (
|
||||||
|
<ellipse cx="58" cy="23" rx="7" ry="3" fill="none" stroke={ring} strokeWidth="0.8" opacity="0.7" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HoverTip ── */
|
||||||
|
|
||||||
|
interface HoverTipProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
useCase?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HoverTip = memo(({ title, description, useCase, children }: HoverTipProps) => {
|
||||||
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
const [coords, setCoords] = useState<{ left: number; top: number; placement: "top" | "bottom" } | null>(null);
|
||||||
|
const anchorRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tipRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const computePlacement = useCallback(() => {
|
||||||
|
const el = anchorRef.current;
|
||||||
|
if (!el) return null;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const tooltipWidth = 260;
|
||||||
|
const margin = 10;
|
||||||
|
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
||||||
|
left = Math.max(margin, Math.min(window.innerWidth - tooltipWidth - margin, left));
|
||||||
|
const placement: "top" | "bottom" = "top";
|
||||||
|
const top = rect.top - margin;
|
||||||
|
return { left, top, placement };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!coords) return;
|
||||||
|
const tip = tipRef.current;
|
||||||
|
const anchor = anchorRef.current;
|
||||||
|
if (!tip || !anchor) return;
|
||||||
|
const tipH = tip.getBoundingClientRect().height;
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const margin = 10;
|
||||||
|
let placement = coords.placement;
|
||||||
|
if (placement === "top" && rect.top - tipH - margin < 0) placement = "bottom";
|
||||||
|
if (placement !== coords.placement) {
|
||||||
|
const top = placement === "top" ? rect.top - margin : rect.bottom + margin;
|
||||||
|
setCoords((c) => (c ? { ...c, placement, top } : c));
|
||||||
|
}
|
||||||
|
}, [coords]);
|
||||||
|
|
||||||
|
const handleEnter = () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
const c = computePlacement();
|
||||||
|
if (c) setCoords(c);
|
||||||
|
}, 900);
|
||||||
|
};
|
||||||
|
const handleLeave = () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
setCoords(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={anchorRef} className="relative" onMouseEnter={handleEnter} onMouseLeave={handleLeave}>
|
||||||
|
{children}
|
||||||
|
{coords &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={tipRef}
|
||||||
|
className="pointer-events-none fixed z-[20000] w-[260px] rounded-lg border p-3 shadow-2xl"
|
||||||
|
style={{
|
||||||
|
left: `${coords.left}px`,
|
||||||
|
top: coords.placement === "bottom" ? `${coords.top}px` : undefined,
|
||||||
|
bottom: coords.placement === "top" ? `${window.innerHeight - coords.top}px` : undefined,
|
||||||
|
borderColor: theme.toolbar.border,
|
||||||
|
background: theme.spatial.elevated,
|
||||||
|
color: theme.node.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[12px] font-semibold">{title}</div>
|
||||||
|
{description && <div className="mt-1 text-[11px] leading-5" style={{ color: theme.node.muted }}>{description}</div>}
|
||||||
|
{useCase && <div className="mt-2 rounded px-2 py-1 text-[10px]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>使用场景:{useCase}</div>}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
HoverTip.displayName = "HoverTip";
|
||||||
|
|
||||||
|
/* ── CardColumn ── */
|
||||||
|
|
||||||
|
interface CardColumnProps {
|
||||||
|
label: string;
|
||||||
|
tooltipTitle: string;
|
||||||
|
tooltipDesc?: string;
|
||||||
|
tooltipUseCase?: string;
|
||||||
|
visual: React.ReactNode;
|
||||||
|
cornerBadge?: React.ReactNode;
|
||||||
|
captionBelow: string;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardColumn = memo(({ label, tooltipTitle, tooltipDesc, tooltipUseCase, visual, cornerBadge, captionBelow, onPrev, onNext, theme }: CardColumnProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrev}
|
||||||
|
className="flex h-5 w-full items-center justify-center rounded-md transition-colors"
|
||||||
|
style={{ color: theme.node.faint }}
|
||||||
|
title="上一项"
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<HoverTip title={tooltipTitle} description={tooltipDesc} useCase={tooltipUseCase}>
|
||||||
|
<div
|
||||||
|
className="relative flex h-[140px] w-[140px] cursor-help flex-col items-center justify-between rounded-xl border px-3 pt-2 pb-2 transition-colors"
|
||||||
|
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover }}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] font-medium tracking-wide" style={{ color: theme.node.muted }}>{label}</span>
|
||||||
|
<div className="flex w-full flex-1 items-center justify-center">{visual}</div>
|
||||||
|
{cornerBadge && (
|
||||||
|
<div className="absolute right-1.5 top-1.5 rounded-md px-1.5 py-0.5 text-[10px] font-medium" style={{ background: "rgba(0,0,0,.5)", color: "rgba(255,255,255,.8)" }}>
|
||||||
|
{cornerBadge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HoverTip>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
className="flex h-5 w-full items-center justify-center rounded-md transition-colors"
|
||||||
|
style={{ color: theme.node.faint }}
|
||||||
|
title="下一项"
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="max-w-full truncate text-center text-[11px] leading-4" style={{ color: theme.node.muted }}>{captionBelow}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CardColumn.displayName = "CardColumn";
|
||||||
|
|
||||||
|
/* ── Main panel ── */
|
||||||
|
|
||||||
|
export function CanvasNodeCameraPanel({
|
||||||
|
cameraControl,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
cameraControl?: CameraControlOptions;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (options: CameraControlOptions, prompt: string) => void;
|
||||||
|
}) {
|
||||||
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
const copyText = useCopyText();
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(cameraControl?.enabled ?? true);
|
||||||
|
const [cameraIdx, setCameraIdx] = useState(() => {
|
||||||
|
const i = CAMERA_PROFILES.findIndex((c) => c.id === cameraControl?.camera);
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
});
|
||||||
|
const [lensIdx, setLensIdx] = useState(() => {
|
||||||
|
const i = LENS_PROFILES.findIndex((l) => l.id === cameraControl?.lens);
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
});
|
||||||
|
const [focal, setFocal] = useState(cameraControl?.focalLength ?? 50);
|
||||||
|
const [aperture, setAperture] = useState(cameraControl?.aperture ?? 4);
|
||||||
|
|
||||||
|
const currentCamera = CAMERA_PROFILES[cameraIdx];
|
||||||
|
const currentLens = LENS_PROFILES[lensIdx];
|
||||||
|
const focalMeta = FOCAL_LENGTH_META[focal];
|
||||||
|
const apertureMeta = APERTURE_META[aperture];
|
||||||
|
|
||||||
|
const summary = useMemo(() => `${currentCamera.zhName} · ${currentLens.zhName} · ${focal}mm · f/${aperture}`, [currentCamera, currentLens, focal, aperture]);
|
||||||
|
|
||||||
|
const cycleCamera = (dir: 1 | -1) => setCameraIdx((i) => (i + dir + CAMERA_PROFILES.length) % CAMERA_PROFILES.length);
|
||||||
|
const cycleLens = (dir: 1 | -1) => setLensIdx((i) => (i + dir + LENS_PROFILES.length) % LENS_PROFILES.length);
|
||||||
|
const cycleFocal = (dir: 1 | -1) => setFocal((f) => {
|
||||||
|
const idx = FOCAL_LENGTHS.indexOf(f as (typeof FOCAL_LENGTHS)[number]);
|
||||||
|
const nextIdx = (idx + dir + FOCAL_LENGTHS.length) % FOCAL_LENGTHS.length;
|
||||||
|
return FOCAL_LENGTHS[nextIdx];
|
||||||
|
});
|
||||||
|
const cycleAperture = (dir: 1 | -1) => setAperture((a) => {
|
||||||
|
const idx = APERTURES.indexOf(a as (typeof APERTURES)[number]);
|
||||||
|
const nextIdx = (idx + dir + APERTURES.length) % APERTURES.length;
|
||||||
|
return APERTURES[nextIdx];
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildPrompt = useCallback(() => {
|
||||||
|
if (!enabled) return "";
|
||||||
|
return buildCameraPrompt({
|
||||||
|
cameraId: currentCamera.id,
|
||||||
|
lensId: currentLens.id,
|
||||||
|
focalLengthMm: focal,
|
||||||
|
apertureF: aperture,
|
||||||
|
});
|
||||||
|
}, [enabled, currentCamera, currentLens, focal, aperture]);
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
const options: CameraControlOptions = { enabled, camera: currentCamera.id, lens: currentLens.id, focalLength: focal, aperture };
|
||||||
|
onConfirm(options, buildPrompt());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setEnabled(defaultCameraControl.enabled);
|
||||||
|
setCameraIdx(0);
|
||||||
|
setLensIdx(0);
|
||||||
|
setFocal(defaultCameraControl.focalLength);
|
||||||
|
setAperture(defaultCameraControl.aperture);
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryButtonClass = "flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-medium transition hover:bg-black/5 dark:hover:bg-white/10";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotlightSurface
|
||||||
|
data-canvas-no-zoom
|
||||||
|
spotlightColor={theme.toolbar.itemHover}
|
||||||
|
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
|
||||||
|
className="w-full overflow-hidden rounded-[var(--r-2xl)] border backdrop-blur-2xl"
|
||||||
|
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Card columns */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<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>
|
||||||
|
{/* Enable toggle */}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 })}
|
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||||
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
|
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
|
||||||
|
cameraControl={node.metadata?.cameraControl}
|
||||||
|
onCameraControlChange={(options) => onConfigChange(node.id, { cameraControl: options })}
|
||||||
/>
|
/>
|
||||||
) : mode === "video" ? (
|
) : mode === "video" ? (
|
||||||
<CanvasVideoSettingsPopover
|
<CanvasVideoSettingsPopover
|
||||||
|
|||||||
310
web/src/lib/canvas/camera-prompt-library.ts
Normal file
310
web/src/lib/canvas/camera-prompt-library.ts
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* 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 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas
|
|||||||
import { canvasGenerationPromptMetadata, canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
|
import { canvasGenerationPromptMetadata, canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
|
||||||
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
|
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
|
||||||
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
|
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 { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
|
||||||
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
|
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
|
||||||
import { modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection";
|
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 sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
|
||||||
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
|
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;
|
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
|
||||||
|
|
||||||
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
|
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-textu
|
|||||||
import type { StyleExecutionPlan } from "@/lib/canvas/style-profile";
|
import type { StyleExecutionPlan } from "@/lib/canvas/style-profile";
|
||||||
import type { PortraitClearanceNodeState } from "@/lib/portrait-clearance/contracts";
|
import type { PortraitClearanceNodeState } from "@/lib/portrait-clearance/contracts";
|
||||||
import type { ArtCritiqueNodeState } from "@/lib/art-critique/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";
|
import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline";
|
||||||
|
|
||||||
export type Position = {
|
export type Position = {
|
||||||
@@ -429,6 +430,8 @@ export type CanvasNodeMetadata = {
|
|||||||
portraitClearance?: PortraitClearanceNodeState;
|
portraitClearance?: PortraitClearanceNodeState;
|
||||||
/** AI 审美批改节点只保存当前报告和输入指纹,不保存图片二进制。 */
|
/** AI 审美批改节点只保存当前报告和输入指纹,不保存图片二进制。 */
|
||||||
artCritique?: ArtCritiqueNodeState;
|
artCritique?: ArtCritiqueNodeState;
|
||||||
|
/** 摄像机控制选项,启用后生成时自动追加摄影机/镜头/焦距/光圈提示词。 */
|
||||||
|
cameraControl?: CameraControlOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasNodeData = {
|
export type CanvasNodeData = {
|
||||||
|
|||||||
Reference in New Issue
Block a user