"use client"; import { useCallback, type MouseEvent, type PointerEvent, } from "react"; import { useI18n, type TranslationKey } from "@/i18n"; export type MultiAnglePresetId = "custom" | "fisheye" | "tilt" | "frontOverhead" | "frontLow" | "panoramicOverhead" | "back"; export type MultiAngleZoom = 0 | 1 | 2; export type MultiAngleVertical = -30 | 0 | 30 | 60; export interface MultiAngleSettings { preset: MultiAnglePresetId; horizontal: number; vertical: MultiAngleVertical; zoom: MultiAngleZoom; includePrompt: boolean; promptText: string; } const MULTI_ANGLE_PROMPT_PATTERN = /\n*\u3010多角度\u3011[\s\S]*?\u3010\/多角度\u3011/g; export const MULTI_ANGLE_PRESETS: Array<{ id: MultiAnglePresetId; label: string; enabled: boolean }> = [ { id: "custom", label: "自定义", enabled: true }, { id: "fisheye", label: "鱼眼视角", enabled: true }, { id: "tilt", label: "倾斜视角", enabled: true }, { id: "frontOverhead", label: "正面俯拍", enabled: true }, { id: "frontLow", label: "正面仰拍", enabled: true }, { id: "panoramicOverhead", label: "全景俯拍", enabled: true }, { id: "back", label: "背面视角", enabled: true }, ]; export const MULTI_ANGLE_HORIZONTAL_MIN = 0; export const MULTI_ANGLE_HORIZONTAL_MAX = 315; export const MULTI_ANGLE_VERTICAL_OPTIONS: MultiAngleVertical[] = [-30, 0, 30, 60]; export const FISHEYE_MULTI_ANGLE_PROMPT = "超近距离鱼眼镜头,强烈超广角透视,圆形画面边界与黑色暗角,明显桶形畸变,画面中心主体放大,边缘向外弯曲拉伸"; export const TILT_MULTI_ANGLE_PROMPT = "dutch angle, tilted frame"; export const MULTI_ANGLE_ZOOM_LABELS: Record = { 0: "全景", 1: "中景", 2: "特写", }; const MULTI_ANGLE_PRESET_LABEL_KEYS: Record = { custom: "multiAngle.preset.custom", fisheye: "multiAngle.preset.fisheye", tilt: "multiAngle.preset.tilt", frontOverhead: "multiAngle.preset.frontOverhead", frontLow: "multiAngle.preset.frontLow", panoramicOverhead: "multiAngle.preset.panoramicOverhead", back: "multiAngle.preset.back", }; const MULTI_ANGLE_ZOOM_LABEL_KEYS: Record = { 0: "multiAngle.zoom.wide", 1: "multiAngle.zoom.medium", 2: "multiAngle.zoom.close", }; export const DEFAULT_MULTI_ANGLE_SETTINGS: MultiAngleSettings = { preset: "custom", horizontal: 0, vertical: -30, zoom: 1, includePrompt: false, promptText: "", }; export const FISHEYE_MULTI_ANGLE_SETTINGS: MultiAngleSettings = { preset: "fisheye", horizontal: 0, vertical: 30, zoom: 2, includePrompt: true, promptText: FISHEYE_MULTI_ANGLE_PROMPT, }; export const MULTI_ANGLE_PRESET_SETTINGS: Record = { custom: DEFAULT_MULTI_ANGLE_SETTINGS, fisheye: FISHEYE_MULTI_ANGLE_SETTINGS, tilt: { preset: "tilt", horizontal: 45, vertical: -30, zoom: 1, includePrompt: true, promptText: TILT_MULTI_ANGLE_PROMPT, }, frontOverhead: { preset: "frontOverhead", horizontal: 0, vertical: 60, zoom: 1, includePrompt: false, promptText: "", }, frontLow: { preset: "frontLow", horizontal: 0, vertical: -30, zoom: 1, includePrompt: false, promptText: "", }, panoramicOverhead: { preset: "panoramicOverhead", horizontal: 45, vertical: 30, zoom: 0, includePrompt: false, promptText: "", }, back: { preset: "back", horizontal: 180, vertical: 0, zoom: 1, includePrompt: false, promptText: "", }, }; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function snapMultiAngleVertical(value: number): MultiAngleVertical { return MULTI_ANGLE_VERTICAL_OPTIONS.reduce((closest, option) => Math.abs(option - value) < Math.abs(closest - value) ? option : closest ); } function getNextMultiAngleVertical(current: MultiAngleVertical, direction: -1 | 1): MultiAngleVertical { const currentIndex = MULTI_ANGLE_VERTICAL_OPTIONS.indexOf(current); const nextIndex = Math.max(0, Math.min(MULTI_ANGLE_VERTICAL_OPTIONS.length - 1, currentIndex + direction)); return MULTI_ANGLE_VERTICAL_OPTIONS[nextIndex] ?? DEFAULT_MULTI_ANGLE_SETTINGS.vertical; } function getHorizontalPrompt(horizontal: number): string { if (horizontal === 0) return "水平环绕 0°,相机正对主体"; const visualAngle = 360 - horizontal; return `水平环绕 ${horizontal}°,按目标视角等效为主体向右转 ${visualAngle}°,不要生成反向视角`; } function getVerticalPrompt(vertical: number): string { if (vertical === 0) return "垂直俯仰 0°"; return vertical > 0 ? `垂直俯仰 ${vertical}°,正面俯拍视角,相机从上方向下看主体` : `垂直俯仰 ${vertical}°,正面仰拍视角,相机从下方向上看主体`; } export function buildMultiAnglePrompt(settings: MultiAngleSettings): string { const customPrompt = settings.includePrompt ? settings.promptText.trim() : ""; const customPromptText = customPrompt ? `,提示词:${customPrompt}` : ""; return `【多角度】基于输入图片生成,保持同一主体和画风。使用${getHorizontalPrompt(settings.horizontal)},${getVerticalPrompt(settings.vertical)},景别为${MULTI_ANGLE_ZOOM_LABELS[settings.zoom]}${customPromptText},构图稳定、画面自然。【/多角度】`; } export function stripMultiAnglePrompt(prompt: string): string { return prompt.replace(MULTI_ANGLE_PROMPT_PATTERN, "").replace(/\n{3,}/g, "\n\n").trimEnd(); } export function mergeMultiAnglePrompt(prompt: string, settings: MultiAngleSettings): string { const basePrompt = stripMultiAnglePrompt(prompt); const anglePrompt = buildMultiAnglePrompt(settings); return basePrompt ? `${basePrompt}\n\n${anglePrompt}` : anglePrompt; } export function MultiAngleEditor({ previewImage, settings, onChange, onSelectPreset, onTogglePrompt, onReset, }: { previewImage: string | null | undefined; settings: MultiAngleSettings; onChange: (patch: Partial) => void; onSelectPreset: (preset: MultiAnglePresetId) => void; onTogglePrompt: (enabled: boolean) => void; onReset: () => void; }) { const { t } = useI18n(); const selectedPreset = MULTI_ANGLE_PRESETS.find((preset) => preset.id === settings.preset) ?? MULTI_ANGLE_PRESETS[0]; const selectedPresetLabel = t(MULTI_ANGLE_PRESET_LABEL_KEYS[selectedPreset.id]); const currentZoomLabel = t(MULTI_ANGLE_ZOOM_LABEL_KEYS[settings.zoom]); const horizontalRadians = (settings.horizontal * Math.PI) / 180; const cameraX = 100 + Math.sin(horizontalRadians) * 78; const cameraY = 100 - (settings.vertical / 60) * 52; const globePitchRotation = -(settings.vertical / 60) * 16; const globePitchScaleY = 1 - Math.abs(settings.vertical) / 260; const sightTargetY = 100; const previewScale = settings.zoom === 0 ? 0.88 : settings.zoom === 1 ? 1 : 1.16; const updateHorizontal = (value: number) => { onChange({ horizontal: Math.round(clamp(value, MULTI_ANGLE_HORIZONTAL_MIN, MULTI_ANGLE_HORIZONTAL_MAX)), }); }; const updateVertical = (value: number) => { onChange({ vertical: snapMultiAngleVertical(value) }); }; const updateZoom = (value: number) => { onChange({ zoom: Math.round(clamp(value, 0, 2)) as MultiAngleZoom }); }; const updateFromGlobePoint = useCallback( (target: HTMLDivElement, clientX: number, clientY: number) => { const rect = target.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; const pointerY = clamp(1 - (clientY - rect.top) / rect.height, 0, 1); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const rawAngle = (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI; const normalizedAngle = (90 - rawAngle + 360) % 360; const horizontal = normalizedAngle > MULTI_ANGLE_HORIZONTAL_MAX ? MULTI_ANGLE_HORIZONTAL_MIN : Math.round(normalizedAngle); onChange({ horizontal, vertical: snapMultiAngleVertical(-30 + pointerY * 90), }); }, [onChange] ); const handleGlobePointerDown = useCallback( (event: PointerEvent) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture?.(event.pointerId); updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); }, [updateFromGlobePoint] ); const handleGlobePointerMove = useCallback( (event: PointerEvent) => { if ((event.buttons & 1) !== 1) return; event.preventDefault(); event.stopPropagation(); updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); }, [updateFromGlobePoint] ); const handleGlobePointerUp = useCallback((event: PointerEvent) => { event.stopPropagation(); event.currentTarget.releasePointerCapture?.(event.pointerId); }, []); const handleGlobeMouseDown = useCallback( (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); }, [updateFromGlobePoint] ); const handleGlobeMouseMove = useCallback( (event: MouseEvent) => { if ((event.buttons & 1) !== 1) return; event.preventDefault(); event.stopPropagation(); updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); }, [updateFromGlobePoint] ); const handleGlobeMouseUp = useCallback((event: MouseEvent) => { event.stopPropagation(); }, []); return (
{t("multiAngle.editorTitle")} {selectedPresetLabel}
{MULTI_ANGLE_PRESETS.map((preset) => ( ))}
{previewImage ? ( ) : (
{t("multiAngle.noImage")}
)}
{t("multiAngle.prompt")}
{settings.includePrompt && (