"use client"; import { useCallback, useRef, useState, type PointerEvent, type ReactNode, } from "react"; import { DownOutlined, LeftOutlined, RightOutlined, UpOutlined } from "@ant-design/icons"; import { useI18n, type TranslationKey } from "@/i18n"; export type MultiAnglePresetId = "custom" | "fisheye" | "tilt" | "frontOverhead" | "frontLow" | "panoramicOverhead" | "back"; export interface MultiAngleSettings { preset: MultiAnglePresetId; horizontal: number; vertical: number; zoom: number; includePrompt: boolean; promptText: string; } const MULTI_ANGLE_PROMPT_PATTERN = /\n*\[multi-angle\][\s\S]*?\[\/multi-angle\]/gi; export const MULTI_ANGLE_PRESETS: Array<{ id: MultiAnglePresetId; label: string; enabled: boolean }> = [ { id: "custom", label: "Custom", enabled: true }, { id: "fisheye", label: "Fisheye", enabled: true }, { id: "tilt", label: "Tilt", enabled: true }, { id: "frontOverhead", label: "Front overhead", enabled: true }, { id: "frontLow", label: "Front low angle", enabled: true }, { id: "panoramicOverhead", label: "Panoramic overhead", enabled: true }, { id: "back", label: "Back view", enabled: true }, ]; export const MULTI_ANGLE_HORIZONTAL_MIN = 0; export const MULTI_ANGLE_HORIZONTAL_MAX = 360; export const MULTI_ANGLE_VERTICAL_MIN = -30; export const MULTI_ANGLE_VERTICAL_MAX = 60; export const MULTI_ANGLE_ZOOM_MIN = 0; export const MULTI_ANGLE_ZOOM_MAX = 10; export const FISHEYE_MULTI_ANGLE_PROMPT = "Ultra-close fisheye lens, strong ultra-wide perspective, circular frame edge, dark vignette, visible barrel distortion, enlarged subject center, stretched curved edges"; export const TILT_MULTI_ANGLE_PROMPT = "Dutch angle, tilted frame"; const CUBE_DRAG_DEGREES_PER_PIXEL = 0.8; 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", }; export const DEFAULT_MULTI_ANGLE_SETTINGS: MultiAngleSettings = { preset: "custom", horizontal: 0, vertical: -30, zoom: 5, includePrompt: false, promptText: "", }; export const FISHEYE_MULTI_ANGLE_SETTINGS: MultiAngleSettings = { preset: "fisheye", horizontal: 0, vertical: 30, zoom: 10, 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: 5, includePrompt: true, promptText: TILT_MULTI_ANGLE_PROMPT, }, frontOverhead: { preset: "frontOverhead", horizontal: 0, vertical: 60, zoom: 5, includePrompt: false, promptText: "", }, frontLow: { preset: "frontLow", horizontal: 0, vertical: -30, zoom: 5, includePrompt: false, promptText: "", }, panoramicOverhead: { preset: "panoramicOverhead", horizontal: 45, vertical: 30, zoom: 0, includePrompt: false, promptText: "", }, back: { preset: "back", horizontal: 180, vertical: 0, zoom: 5, includePrompt: false, promptText: "", }, }; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function roundToStep(value: number, precision = 1): number { const factor = 10 ** precision; return Math.round(value * factor) / factor; } function normalizeHorizontalDragAngle(value: number): number { return Math.round(((value % 360) + 360) % 360); } function getNextMultiAngleVertical(current: number, direction: -1 | 1): number { return Math.round(clamp(current + direction, MULTI_ANGLE_VERTICAL_MIN, MULTI_ANGLE_VERTICAL_MAX)); } function getHorizontalPrompt(horizontal: number): string { if (horizontal === 0) return "horizontal orbit 0 degrees, camera faces the subject"; return `horizontal orbit ${horizontal} degrees counterclockwise around the subject, preserve the target viewpoint and avoid reversed viewpoint`; } function getVerticalPrompt(vertical: number): string { if (vertical === 0) return "vertical pitch 0 degrees"; return vertical > 0 ? `vertical pitch ${vertical} degrees, front overhead view, camera looks down at the subject` : `vertical pitch ${vertical} degrees, front low angle view, camera looks up at the subject`; } export function buildMultiAnglePrompt(settings: MultiAngleSettings): string { const customPrompt = settings.includePrompt ? settings.promptText.trim() : ""; const customPromptText = customPrompt ? `, prompt: ${customPrompt}` : ""; return `[multi-angle] Generate a normal 2D image from the input image while preserving the same subject and visual style. Use ${getHorizontalPrompt(settings.horizontal)}, ${getVerticalPrompt(settings.vertical)}, shot scale zoom ${settings.zoom}${customPromptText}. Do not create a 3D model, model sheet, mesh preview, or turntable render. Keep composition stable and natural. [/multi-angle]`; } 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, compact = false, hidePromptControl = false, actions, }: { previewImage: string | null | undefined; settings: MultiAngleSettings; onChange: (patch: Partial) => void; onSelectPreset: (preset: MultiAnglePresetId) => void; onTogglePrompt: (enabled: boolean) => void; onReset: () => void; compact?: boolean; hidePromptControl?: boolean; actions?: ReactNode; }) { const { t } = useI18n(); const [isCubeDragging, setIsCubeDragging] = useState(false); const cubeDragRef = useRef<{ pointerId: number; startX: number; startY: number; startHorizontal: number; startVertical: number; } | null>(null); const previewScale = 0.58 + (settings.zoom / MULTI_ANGLE_ZOOM_MAX) * 0.64; const cubeRotateX = -settings.vertical; const cubeRotateY = settings.horizontal; const cubeFaceClass = "absolute inset-0 flex items-center justify-center rounded-xl border border-[var(--border-strong)]/55 bg-[var(--surface-2)]/35 text-xs font-medium text-[var(--text-muted)] [backface-visibility:visible]"; const updateHorizontal = (value: number) => { onChange({ horizontal: Math.round(clamp(value, MULTI_ANGLE_HORIZONTAL_MIN, MULTI_ANGLE_HORIZONTAL_MAX)), }); }; const updateVertical = (value: number) => { onChange({ vertical: Math.round(clamp(value, MULTI_ANGLE_VERTICAL_MIN, MULTI_ANGLE_VERTICAL_MAX)) }); }; const updateZoom = (value: number) => { onChange({ zoom: roundToStep(clamp(value, MULTI_ANGLE_ZOOM_MIN, MULTI_ANGLE_ZOOM_MAX), 1) }); }; const handleGlobePointerDown = useCallback( (event: PointerEvent) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture?.(event.pointerId); cubeDragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startHorizontal: settings.horizontal, startVertical: settings.vertical, }; setIsCubeDragging(true); }, [settings.horizontal, settings.vertical] ); const handleGlobePointerMove = useCallback( (event: PointerEvent) => { const dragState = cubeDragRef.current; if (!dragState || dragState.pointerId !== event.pointerId) return; if (event.pointerType === "mouse" && (event.buttons & 1) !== 1) return; event.preventDefault(); event.stopPropagation(); const deltaX = event.clientX - dragState.startX; const deltaY = event.clientY - dragState.startY; onChange({ horizontal: normalizeHorizontalDragAngle(dragState.startHorizontal + deltaX * CUBE_DRAG_DEGREES_PER_PIXEL), vertical: Math.round( clamp( dragState.startVertical - deltaY * CUBE_DRAG_DEGREES_PER_PIXEL, MULTI_ANGLE_VERTICAL_MIN, MULTI_ANGLE_VERTICAL_MAX ) ), }); }, [onChange] ); const handleGlobePointerUp = useCallback((event: PointerEvent) => { event.stopPropagation(); event.currentTarget.releasePointerCapture?.(event.pointerId); if (cubeDragRef.current?.pointerId === event.pointerId) { cubeDragRef.current = null; setIsCubeDragging(false); } }, []); const handleGlobePointerCancel = useCallback((event: PointerEvent) => { event.stopPropagation(); if (cubeDragRef.current?.pointerId === event.pointerId) { cubeDragRef.current = null; setIsCubeDragging(false); } }, []); return (
{t("multiAngle.editorTitle")}
{MULTI_ANGLE_PRESETS.map((preset) => ( ))}
{previewImage ? ( ) : (
{t("multiAngle.noImage")}
)}
{!hidePromptControl && (
{t("multiAngle.prompt")}
)} {!hidePromptControl && settings.includePrompt && (