From 796cdf5ec10adb2a8fd9c544ac62447240ff3ad2 Mon Sep 17 00:00:00 2001 From: xuzhijie Date: Thu, 14 May 2026 10:43:35 +0800 Subject: [PATCH] Refine lighting preview geometry --- .../lighting/LightingEffectModal.test.tsx | 89 +++++- .../lighting/LightingEffectModal.tsx | 301 +++++++++++++----- src/utils/__tests__/lighting.test.ts | 50 ++- src/utils/lighting.ts | 4 +- 4 files changed, 350 insertions(+), 94 deletions(-) diff --git a/src/components/lighting/LightingEffectModal.test.tsx b/src/components/lighting/LightingEffectModal.test.tsx index 510994e9..1c50ad1d 100644 --- a/src/components/lighting/LightingEffectModal.test.tsx +++ b/src/components/lighting/LightingEffectModal.test.tsx @@ -37,7 +37,7 @@ describe("LightingEffectModal preview", () => { it("keeps the source image flat in front view", () => { renderLightingModal({ viewMode: "front", - lightPosition: { x: -0.58, y: -0.58, z: 0.58 }, + lightPosition: { x: -0.5, y: -0.71, z: 0.5 }, }); const subject = screen.getByTestId("lighting-preview-subject"); @@ -51,7 +51,7 @@ describe("LightingEffectModal preview", () => { it("shows the source image clearly angled in perspective view", () => { renderLightingModal({ viewMode: "perspective", - lightPosition: { x: -0.58, y: -0.58, z: 0.58 }, + lightPosition: { x: -0.5, y: -0.71, z: 0.5 }, }); const subject = screen.getByTestId("lighting-preview-subject"); @@ -65,24 +65,97 @@ describe("LightingEffectModal preview", () => { expect(beam.style.transform).toContain("rotate("); }); - it("places front light at center in perspective view", () => { + it("places front light on the front latitude-longitude point in perspective view", () => { renderLightingModal({ viewMode: "perspective", lightPosition: { x: 0, y: 0, z: 1 }, }); const beam = screen.getByTestId("lighting-preview-beam"); - - // Front light (z+) projects at center since yaw=0, shifted down by pitch - expect(Number.parseFloat(beam.style.left)).toBeGreaterThan(45); - expect(Number.parseFloat(beam.style.left)).toBeLessThan(55); + const source = screen.getByTestId("lighting-preview-main-source"); + const frontSnapPoint = screen.getByLabelText("Light stop latitude-lat-0-lon-0"); + + expect(source.style.left).toBe(frontSnapPoint.style.left); + expect(source.style.top).toBe(frontSnapPoint.style.top); + expect(Number.parseFloat(source.style.left)).toBeGreaterThan(14); + expect(Number.parseFloat(source.style.left)).toBeLessThan(22); + expect(Number.parseFloat(source.style.top)).toBeGreaterThan(62); + expect(beam.style.left).toBe(source.style.left); expect(beam.style.transform).toContain("rotate("); }); + it("moves the same front light to the center in front view", () => { + renderLightingModal({ + viewMode: "front", + lightPosition: { x: 0, y: 0, z: 1 }, + }); + + const source = screen.getByTestId("lighting-preview-main-source"); + const beam = screen.getByTestId("lighting-preview-beam"); + const glow = screen.getByTestId("lighting-preview-front-glow"); + const frontSnapPoint = screen.getByLabelText("Light stop latitude-lat-0-lon-0"); + + expect(source.style.left).toBe(frontSnapPoint.style.left); + expect(source.style.top).toBe(frontSnapPoint.style.top); + expect(source.style.left).toBe("50%"); + expect(source.style.top).toBe("50%"); + expect(beam.style.opacity).toBe("0"); + expect(Number.parseFloat(glow.style.opacity)).toBeGreaterThan(0); + }); + + it("hides the back light behind the subject in front view", () => { + renderLightingModal({ + viewMode: "front", + lightPosition: { x: 0, y: 0, z: -1 }, + }); + + const source = screen.getByTestId("lighting-preview-main-source"); + const backSnapPoint = screen.getByLabelText("Light stop latitude-lat-0-lon-180"); + + expect(Number.parseFloat(source.style.left)).toBeCloseTo(Number.parseFloat(backSnapPoint.style.left)); + expect(Number.parseFloat(source.style.top)).toBeCloseTo(Number.parseFloat(backSnapPoint.style.top)); + expect(source.style.left).toBe("50%"); + expect(source.style.top).toBe("50%"); + expect(source.style.opacity).toBe("0"); + expect(screen.queryByTestId("lighting-preview-beam")).toBeNull(); + }); + + it("keeps the pole points visible in front view", () => { + renderLightingModal({ + viewMode: "front", + }); + + const poles = screen.getAllByTestId("lighting-globe-pole"); + const northPole = poles.find((pole) => pole.getAttribute("data-pole") === "north"); + const southPole = poles.find((pole) => pole.getAttribute("data-pole") === "south"); + + expect(poles).toHaveLength(2); + expect(northPole?.getAttribute("cx")).toBe("50"); + expect(Number.parseFloat(northPole?.getAttribute("cy") ?? "")).toBeCloseTo(7); + expect(southPole?.getAttribute("cx")).toBe("50"); + expect(Number.parseFloat(southPole?.getAttribute("cy") ?? "")).toBeCloseTo(93); + }); + + it("preserves the loaded source image aspect ratio in the preview subject", () => { + renderLightingModal({ + viewMode: "front", + }); + + const image = screen.getByTestId("lighting-preview-subject-image") as HTMLImageElement; + Object.defineProperty(image, "naturalWidth", { value: 480, configurable: true }); + Object.defineProperty(image, "naturalHeight", { value: 960, configurable: true }); + fireEvent.load(image); + + const subject = screen.getByTestId("lighting-preview-subject"); + + expect(subject.style.width).toBe("20%"); + expect(subject.style.height).toBe("40%"); + }); + it("does not move the source image when changing light position in perspective view", () => { renderLightingModal({ viewMode: "perspective", - lightPosition: { x: -0.58, y: -0.58, z: 0.58 }, + lightPosition: { x: -0.5, y: -0.71, z: 0.5 }, }); const subject = screen.getByTestId("lighting-preview-subject"); diff --git a/src/components/lighting/LightingEffectModal.tsx b/src/components/lighting/LightingEffectModal.tsx index ebab3071..e10adbce 100644 --- a/src/components/lighting/LightingEffectModal.tsx +++ b/src/components/lighting/LightingEffectModal.tsx @@ -26,6 +26,54 @@ const VIEW_MODES: Array<{ id: LightingViewMode; label: string }> = [ { id: "front", label: "正面" }, ]; +const PRESET_THUMBNAILS: Record = { + "overexposed-film": { + background: + "radial-gradient(circle at 42% 18%, #fff8ec 0 18%, transparent 36%), linear-gradient(145deg, #fff2dd 0%, #f2c39b 42%, #201615 100%)", + key: "#fff7e6", + glow: "rgba(255,244,218,0.72)", + }, + "blue-backlight": { + background: + "radial-gradient(circle at 55% 16%, #62a7ff 0 10%, transparent 34%), linear-gradient(150deg, #06102a 0%, #111a4f 56%, #090a12 100%)", + key: "#3446ff", + glow: "rgba(55,90,255,0.72)", + }, + rembrandt: { + background: "linear-gradient(145deg, #2d1b12 0%, #a46b39 48%, #080606 100%)", + key: "#f5c27a", + glow: "rgba(245,170,92,0.7)", + }, + cyberpunk: { + background: "linear-gradient(145deg, #04141c 0%, #00d4ff 48%, #ff3d9e 100%)", + key: "#18e2ff", + glow: "rgba(0,214,255,0.76)", + }, + "sunset-dream": { + background: + "radial-gradient(circle at 34% 30%, #ffe15c 0 14%, transparent 42%), linear-gradient(145deg, #ff8a18 0%, #0b7f55 54%, #08130f 100%)", + key: "#ff9d2e", + glow: "rgba(255,142,36,0.72)", + }, + "mystic-low-key": { + background: + "radial-gradient(circle at 50% 10%, #7a8c7e 0 12%, transparent 34%), linear-gradient(145deg, #080b09 0%, #131714 56%, #030303 100%)", + key: "#d8e6d2", + glow: "rgba(205,232,214,0.34)", + }, + "golden-hour": { + background: + "radial-gradient(circle at 82% 18%, #fff1aa 0 16%, transparent 42%), linear-gradient(145deg, #5a2f0d 0%, #d6902a 52%, #fff0b7 100%)", + key: "#ffd66d", + glow: "rgba(255,194,82,0.68)", + }, + "cool-morning": { + background: "linear-gradient(145deg, #d7e0e8 0%, #8190a0 48%, #2b3139 100%)", + key: "#dce8f2", + glow: "rgba(198,219,236,0.46)", + }, +}; + function readImageFile(file: File): Promise { if (!file.type.match(/^image\/(png|jpeg|webp)$/)) return Promise.resolve(null); return new Promise((resolve) => { @@ -227,9 +275,10 @@ const RIM_LIGHT_STOPS: LightControlPoint[] = [ ]; const DEFAULT_RIM_LIGHT_POSITION = { x: 0, y: 0, z: -1 }; +const DEFAULT_SUBJECT_ASPECT_RATIO = 0.58; const VIEW_ROTATION: Record = { - perspective: { pitch: -28, yaw: 0 }, + perspective: { pitch: -32, yaw: -50 }, front: { pitch: 0, yaw: 0 }, }; @@ -257,12 +306,12 @@ function rotateY(position: LightingPosition, degrees: number): LightingPosition function positionToView(position: LightingPosition, viewMode: LightingViewMode): LightingPosition { const rotation = VIEW_ROTATION[viewMode]; - return rotateY(rotateX(position, rotation.pitch), rotation.yaw); + return rotateX(rotateY(position, rotation.yaw), rotation.pitch); } function positionFromView(position: LightingPosition, viewMode: LightingViewMode): LightingPosition { const rotation = VIEW_ROTATION[viewMode]; - return rotateX(rotateY(position, -rotation.yaw), -rotation.pitch); + return rotateY(rotateX(position, -rotation.pitch), -rotation.yaw); } interface GlobeLineSegment { @@ -433,6 +482,21 @@ function getSubjectProjection(viewMode: LightingViewMode) { }; } +function getSubjectDimensions(viewMode: LightingViewMode, aspectRatio: number) { + const safeAspectRatio = clamp(aspectRatio, 0.32, 2.4); + const maxHeight = viewMode === "front" ? 40 : 42; + const maxWidth = viewMode === "front" ? 32 : 34; + let width = Math.min(maxWidth, maxHeight * safeAspectRatio); + let height = width / safeAspectRatio; + + if (height > maxHeight) { + height = maxHeight; + width = height * safeAspectRatio; + } + + return { width, height }; +} + function getBeamGeometry(source: PreviewPoint, target: PreviewPoint, viewMode: LightingViewMode) { const deltaX = target.left - source.left; const deltaY = target.top - source.top; @@ -534,6 +598,7 @@ function LightingPreview({ const [draftPosition, setDraftPosition] = useState(settings.lightPosition); const [draftRimPosition, setDraftRimPosition] = useState(settings.rimLightPosition ?? DEFAULT_RIM_LIGHT_POSITION); const [dragTarget, setDragTarget] = useState<"main" | "rim" | null>(null); + const [sourceAspectRatio, setSourceAspectRatio] = useState(DEFAULT_SUBJECT_ASPECT_RATIO); const position = dragTarget === "main" ? draftPosition : settings.lightPosition; const rimPosition = normalizePosition( dragTarget === "rim" ? draftRimPosition : (settings.rimLightPosition ?? DEFAULT_RIM_LIGHT_POSITION) @@ -541,15 +606,19 @@ function LightingPreview({ const projected: PreviewPoint = projectPositionToSphere(position, settings.viewMode); const rimProjected: PreviewPoint = projectPositionToSphere(rimPosition, settings.viewMode); const subject = getSubjectProjection(settings.viewMode); + const subjectDimensions = getSubjectDimensions(settings.viewMode, sourceAspectRatio); const subjectHighlight = getSubjectHighlight(position); + const isCenteredFrontLight = position.z > 0.8 && Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2; const beam = getBeamGeometry(projected, subject, settings.viewMode); const rimBeam = getBeamGeometry(rimProjected, subject, settings.viewMode); const opacity = Math.max(0.2, Math.min(0.9, settings.brightness / 100)); const beamSpread = Math.abs(settings.beamAngle) === 90 ? 30 : 22; - const isPureFrontLight = - settings.viewMode === "front" && position.z > 0.8 && Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2; + const isPureFrontLight = settings.viewMode === "front" && isCenteredFrontLight; const isPureBackLight = position.z < -0.8 && Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2; + const isFrontBackLight = settings.viewMode === "front" && isPureBackLight; const isBackLight = position.z < -0.2; + const mainBeamOpacity = isPureFrontLight || isFrontBackLight ? 0 : isBackLight ? opacity * 0.55 : opacity; + const subjectLightOpacity = isFrontBackLight ? 0 : isBackLight ? opacity * 0.18 : opacity * 0.55; const globeLines = useMemo( () => buildVisualGlobeLines(settings.viewMode), [settings.viewMode] @@ -575,6 +644,10 @@ function LightingPreview({ } }, [dragTarget, settings.rimLightPosition]); + useEffect(() => { + setSourceAspectRatio(DEFAULT_SUBJECT_ASPECT_RATIO); + }, [sourceImage]); + const updateDraftPosition = (event: ReactPointerEvent, target: "main" | "rim") => { const pointer = readSpherePointer(event, sphereRef.current); if (target === "rim") { @@ -620,11 +693,11 @@ function LightingPreview({ }; return ( -
+
{ event.currentTarget.setPointerCapture(event.pointerId); setDragTarget("main"); @@ -638,6 +711,7 @@ function LightingPreview({ onPointerCancel={commitDraftPosition} aria-label="拖拽光源控制点" > +
)} - {!isPureFrontLight && !isPureBackLight && ( + {!isFrontBackLight && (
@@ -743,14 +829,15 @@ function LightingPreview({ {settings.rimLight && (
{sourceImage ? ( // eslint-disable-next-line @next/next/no-img-element - + { + const { naturalWidth, naturalHeight } = event.currentTarget; + if (naturalWidth > 0 && naturalHeight > 0) { + setSourceAspectRatio(naturalWidth / naturalHeight); + } + }} + /> ) : (
)} @@ -780,7 +882,7 @@ function LightingPreview({ className="pointer-events-none absolute inset-0 mix-blend-screen" style={{ background: `radial-gradient(circle at ${subjectHighlight.left}% ${subjectHighlight.top}%, ${settings.color}aa 0%, ${settings.color}55 34%, transparent 72%)`, - opacity: isBackLight ? opacity * 0.18 : opacity * 0.55, + opacity: subjectLightOpacity, }} />
@@ -788,7 +890,7 @@ function LightingPreview({ {settings.rimLight && (
@@ -984,6 +1086,36 @@ function LightingColorPicker({ ); } +function LightingPresetThumbnail({ preset }: { preset: (typeof LIGHTING_PRESETS)[number] }) { + const thumbnail = PRESET_THUMBNAILS[preset.id] ?? { + background: preset.thumbnail, + key: "#ffffff", + glow: "rgba(255,255,255,0.5)", + }; + + return ( + <> + + + + + + + ); +} + export function LightingEffectModal({ isOpen, value, @@ -1006,7 +1138,7 @@ export function LightingEffectModal({ } }, [isOpen, value]); - const modalWidthClass = settings.smartMode ? "max-w-[760px]" : "max-w-[458px]"; + const modalWidthClass = settings.smartMode ? "max-w-[754px]" : "max-w-[454px]"; // 预览始终显示原始图片(sourceImage)作为打光主体,参考图不替代原图 const appliedPreviewImage = sourceImage; @@ -1091,33 +1223,39 @@ export function LightingEffectModal({
-
+
-
- {VIEW_MODES.map((mode) => ( - - ))} +
+
+ {VIEW_MODES.map((mode) => ( + + ))} +
+ + patchSettings({ + lightPosition, + mainLightDirection: positionToDirection(lightPosition, settings.mainLightDirection), + }) + } + onRimLightPositionChange={(rimLightPosition) => patchSettings({ rimLightPosition })} + />
- - patchSettings({ - lightPosition, - mainLightDirection: positionToDirection(lightPosition, settings.mainLightDirection), - }) - } - onRimLightPositionChange={(rimLightPosition) => patchSettings({ rimLightPosition })} - />
@@ -1150,10 +1289,10 @@ export function LightingEffectModal({ max={100} value={settings.brightness} onChange={(event) => patchSettings({ brightness: Number(event.target.value) })} - className="w-full accent-neutral-200" + className="h-1.5 w-full accent-neutral-200" aria-label="打光亮度" /> - + {settings.brightness}%
@@ -1180,10 +1319,10 @@ export function LightingEffectModal({ lightPosition: directionToPosition(direction.id), }) } - className={`rounded-lg border px-3 py-2 text-xs transition-colors ${ + className={`h-8 rounded-lg border px-3 text-xs transition-colors ${ isCardinalPosition(settings.lightPosition, direction.id) - ? "border-neutral-400 bg-neutral-600 text-white" - : "border-neutral-700 bg-neutral-900 text-neutral-400 hover:border-neutral-500 hover:text-neutral-100" + ? "border-neutral-300 bg-neutral-600/80 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]" + : "border-neutral-700 bg-neutral-950/20 text-neutral-400 hover:border-neutral-500 hover:text-neutral-100" }`} > {direction.label} @@ -1210,19 +1349,19 @@ export function LightingEffectModal({
{settings.smartMode && ( -
+
智能模式
-
+