Browse Source

Refine lighting preview geometry

TEST-x
xuzhijie 2 months ago
parent
commit
796cdf5ec1
  1. 89
      src/components/lighting/LightingEffectModal.test.tsx
  2. 301
      src/components/lighting/LightingEffectModal.tsx
  3. 50
      src/utils/__tests__/lighting.test.ts
  4. 4
      src/utils/lighting.ts

89
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");

301
src/components/lighting/LightingEffectModal.tsx

@ -26,6 +26,54 @@ const VIEW_MODES: Array<{ id: LightingViewMode; label: string }> = [
{ id: "front", label: "正面" },
];
const PRESET_THUMBNAILS: Record<string, { background: string; key: string; glow: string }> = {
"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<string | null> {
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<LightingViewMode, { pitch: number; yaw: number }> = {
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<HTMLElement>, target: "main" | "rim") => {
const pointer = readSpherePointer(event, sphereRef.current);
if (target === "rim") {
@ -620,11 +693,11 @@ function LightingPreview({
};
return (
<div className="relative h-56 overflow-hidden rounded-lg bg-[radial-gradient(circle_at_50%_44%,rgba(255,255,255,0.055),rgba(18,18,18,0.98)_64%,rgba(12,12,12,1)_100%)]">
<div className="relative h-56 overflow-hidden rounded-b-xl bg-[radial-gradient(circle_at_50%_45%,rgba(255,255,255,0.08),rgba(35,35,35,0.78)_44%,rgba(18,18,18,0.98)_74%)]">
<div
ref={sphereRef}
data-testid="lighting-preview-sphere"
className="absolute left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 touch-none overflow-visible rounded-full"
className="absolute left-1/2 top-1/2 h-[178px] w-[178px] -translate-x-1/2 -translate-y-1/2 touch-none overflow-visible rounded-full"
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
setDragTarget("main");
@ -638,6 +711,7 @@ function LightingPreview({
onPointerCancel={commitDraftPosition}
aria-label="拖拽光源控制点"
>
<div className="pointer-events-none absolute inset-0 z-[1] rounded-full bg-[radial-gradient(circle_at_36%_28%,rgba(255,255,255,0.16),rgba(255,255,255,0.055)_34%,rgba(0,0,0,0.23)_76%,rgba(255,255,255,0.08)_100%)] shadow-[inset_-24px_-18px_45px_rgba(0,0,0,0.35),inset_16px_12px_28px_rgba(255,255,255,0.05)]" />
<svg className="pointer-events-none absolute inset-0 z-[2] h-full w-full" viewBox="0 0 100 100" aria-hidden="true">
{globeLines.map((line) => (
<g key={line.id} data-testid="lighting-globe-line" data-line-kind={line.kind}>
@ -655,20 +729,30 @@ function LightingPreview({
))}
</g>
))}
{settings.viewMode === "perspective" && (
<>
{(() => {
const northPole = projectPositionToSphere({ x: 0, y: -1, z: 0 }, "perspective");
const southPole = projectPositionToSphere({ x: 0, y: 1, z: 0 }, "perspective");
return (
<>
<circle cx={northPole.left} cy={northPole.top} r={1.2} fill="rgba(255,255,255,0.6)" />
<circle cx={southPole.left} cy={southPole.top} r={1.2} fill="rgba(255,255,255,0.6)" />
</>
);
})()}
</>
)}
{(() => {
const northPole = projectPositionToSphere({ x: 0, y: -1, z: 0 }, settings.viewMode);
const southPole = projectPositionToSphere({ x: 0, y: 1, z: 0 }, settings.viewMode);
return (
<>
<circle
data-testid="lighting-globe-pole"
data-pole="north"
cx={northPole.left}
cy={northPole.top}
r={1.2}
fill="rgba(255,255,255,0.6)"
/>
<circle
data-testid="lighting-globe-pole"
data-pole="south"
cx={southPole.left}
cy={southPole.top}
r={1.2}
fill="rgba(255,255,255,0.6)"
/>
</>
);
})()}
{LATITUDE_DEGREES.flatMap((latitude) =>
LONGITUDE_DEGREES.map((longitude) => {
const pt = projectSpherePoint(latitude, longitude, settings.viewMode);
@ -711,30 +795,32 @@ function LightingPreview({
);
})}
{isPureFrontLight && (
{isCenteredFrontLight && (
<div
className="absolute z-[5] h-24 w-24 -translate-x-1/2 -translate-y-1/2 rounded-full blur-sm"
data-testid="lighting-preview-front-glow"
className="absolute z-[5] h-24 w-24 -translate-x-1/2 -translate-y-1/2 rounded-full blur-sm transition-[left,top,opacity,transform] duration-300 ease-out"
style={{
left: `${projected.left}%`,
top: `${projected.top}%`,
background: `radial-gradient(circle, ${settings.color}dd 0%, ${settings.color}55 42%, transparent 72%)`,
opacity,
opacity: isPureFrontLight ? opacity : 0,
}}
/>
)}
{!isPureFrontLight && !isPureBackLight && (
{!isFrontBackLight && (
<div
data-testid="lighting-preview-beam"
className="absolute z-[5] origin-left blur-[1px]"
className="absolute z-[5] origin-left blur-[1px] mix-blend-screen transition-[left,top,width,height,opacity,transform,filter] duration-300 ease-out"
style={{
left: `${projected.left}%`,
top: `${projected.top}%`,
width: `${formatCssNumber(beam.length)}%`,
height: `${beamSpread}%`,
background: `linear-gradient(90deg, ${settings.color}ee, ${settings.color}66 46%, transparent 88%)`,
clipPath: "polygon(0 50%, 100% 8%, 100% 92%)",
opacity: isBackLight ? opacity * 0.55 : opacity,
height: `${Math.max(18, beamSpread)}%`,
background: `linear-gradient(90deg, ${settings.color}f2 0%, ${settings.color}9c 36%, ${settings.color}34 74%, transparent 100%)`,
clipPath: "polygon(0 50%, 100% 0, 100% 100%)",
filter: `drop-shadow(0 0 8px ${settings.color}66)`,
opacity: mainBeamOpacity,
transform: `translateY(-50%) rotate(${formatCssNumber(beam.angle)}deg)`,
}}
/>
@ -743,14 +829,15 @@ function LightingPreview({
{settings.rimLight && (
<div
data-testid="lighting-preview-rim-beam"
className="absolute z-[4] origin-left blur-[1px]"
className="absolute z-[4] origin-left blur-[1px] mix-blend-screen"
style={{
left: `${rimProjected.left}%`,
top: `${rimProjected.top}%`,
width: `${formatCssNumber(rimBeam.length)}%`,
height: `${beamSpread}%`,
background: `linear-gradient(90deg, ${settings.color}cc, ${settings.color}44 46%, transparent 88%)`,
clipPath: "polygon(0 50%, 100% 8%, 100% 92%)",
height: `${Math.max(16, beamSpread * 0.85)}%`,
background: `linear-gradient(90deg, ${settings.color}cc 0%, ${settings.color}66 42%, transparent 92%)`,
clipPath: "polygon(0 50%, 100% 0, 100% 100%)",
filter: `drop-shadow(0 0 7px ${settings.color}55)`,
opacity: opacity * 0.48,
transform: `translateY(-50%) rotate(${formatCssNumber(rimBeam.angle)}deg)`,
}}
@ -759,20 +846,35 @@ function LightingPreview({
<div
data-testid="lighting-preview-subject"
className={`absolute z-20 aspect-square overflow-hidden rounded-sm border border-white/20 bg-neutral-700 shadow-2xl ${
className={`absolute z-20 overflow-hidden rounded-[3px] border border-white/25 bg-neutral-700 shadow-[0_16px_34px_rgba(0,0,0,0.45)] transition-[left,top,width,height,transform,box-shadow] duration-300 ease-out ${
settings.rimLight ? "ring-2 ring-cyan-200/80" : ""
}`}
style={{
left: `${formatCssNumber(subject.left)}%`,
top: `${formatCssNumber(subject.top)}%`,
width: `${subject.width}%`,
width: `${formatCssNumber(subjectDimensions.width)}%`,
height: `${formatCssNumber(subjectDimensions.height)}%`,
boxShadow: isPureBackLight && !isFrontBackLight
? `0 0 0 1px ${settings.color}88, 0 0 22px ${settings.color}55, 0 16px 34px rgba(0,0,0,0.45)`
: undefined,
transform: subjectTransform,
transformStyle: "preserve-3d",
}}
>
{sourceImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={sourceImage} alt="" className="h-full w-full object-cover" />
<img
src={sourceImage}
alt=""
data-testid="lighting-preview-subject-image"
className="h-full w-full object-contain"
onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget;
if (naturalWidth > 0 && naturalHeight > 0) {
setSourceAspectRatio(naturalWidth / naturalHeight);
}
}}
/>
) : (
<div className="h-full w-full bg-gradient-to-b from-neutral-500 to-neutral-800" />
)}
@ -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,
}}
/>
</div>
@ -788,7 +890,7 @@ function LightingPreview({
{settings.rimLight && (
<div
data-testid="lighting-preview-rim-source"
className="absolute z-50 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full bg-black shadow-[0_0_0_2px_rgba(255,255,255,0.95),0_0_18px_rgba(255,255,255,0.88)] active:cursor-grabbing"
className="absolute z-50 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full bg-black shadow-[0_0_0_2px_rgba(255,255,255,0.95),0_0_18px_rgba(255,255,255,0.88)] active:cursor-grabbing"
style={{
left: `${rimProjected.left}%`,
top: `${rimProjected.top}%`,
@ -805,11 +907,11 @@ function LightingPreview({
<div
data-testid="lighting-preview-main-source"
className="absolute z-50 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full bg-black shadow-[0_0_0_2px_rgba(255,255,255,0.95),0_0_18px_rgba(255,255,255,0.88)] active:cursor-grabbing"
className="absolute z-50 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full bg-black shadow-[0_0_0_2px_rgba(255,255,255,0.95),0_0_18px_rgba(255,255,255,0.88)] transition-[left,top,opacity,transform] duration-300 ease-out active:cursor-grabbing"
style={{
left: `${projected.left}%`,
top: `${projected.top}%`,
opacity: isPureBackLight ? 0.82 : 1,
opacity: isFrontBackLight ? 0 : isPureBackLight ? 0.82 : 1,
}}
/>
@ -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 (
<>
<span className="absolute inset-0" style={{ background: thumbnail.background }} />
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.1),transparent_42%,rgba(0,0,0,0.7))]" />
<span
className="absolute left-1/2 top-[40%] h-8 w-6 -translate-x-1/2 rounded-full bg-neutral-950/80"
style={{ boxShadow: `0 0 22px ${thumbnail.glow}` }}
/>
<span
className="absolute left-1/2 top-[62%] h-8 w-9 -translate-x-1/2 rounded-t-full bg-neutral-950/85"
style={{ boxShadow: `0 -8px 18px ${thumbnail.glow}` }}
/>
<span
className="absolute left-[18%] top-[46%] h-0.5 w-[68%] origin-left rounded-full opacity-80"
style={{
background: `linear-gradient(90deg, ${thumbnail.key}, transparent)`,
transform: preset.id === "blue-backlight" || preset.id === "cyberpunk" ? "rotate(-28deg)" : "rotate(-12deg)",
}}
/>
</>
);
}
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({
</button>
</div>
<div className={`grid gap-4 p-4 ${settings.smartMode ? "md:grid-cols-[200px_190px_1fr]" : "md:grid-cols-[200px_190px]"}`}>
<div
className={`grid gap-4 p-4 ${
settings.smartMode ? "md:grid-cols-[204px_200px_minmax(280px,1fr)]" : "md:grid-cols-[204px_200px]"
}`}
>
<div className="space-y-3">
<div className="grid grid-cols-2 rounded-xl border border-neutral-700 bg-neutral-900 p-1">
{VIEW_MODES.map((mode) => (
<button
key={mode.id}
type="button"
onClick={() => patchSettings({ viewMode: mode.id })}
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
settings.viewMode === mode.id ? "bg-neutral-700 text-white" : "text-neutral-500 hover:text-neutral-200"
}`}
>
{mode.label}
</button>
))}
<div className="overflow-hidden rounded-xl bg-neutral-950/40 p-2 shadow-inner shadow-black/20">
<div className="grid grid-cols-2 rounded-xl border border-neutral-700/80 bg-neutral-900/80 p-1">
{VIEW_MODES.map((mode) => (
<button
key={mode.id}
type="button"
onClick={() => patchSettings({ viewMode: mode.id })}
className={`h-9 rounded-lg px-3 text-sm transition-colors ${
settings.viewMode === mode.id ? "bg-neutral-700/80 text-white" : "text-neutral-500 hover:text-neutral-200"
}`}
>
{mode.label}
</button>
))}
</div>
<LightingPreview
settings={settings}
sourceImage={appliedPreviewImage}
onLightPositionChange={(lightPosition) =>
patchSettings({
lightPosition,
mainLightDirection: positionToDirection(lightPosition, settings.mainLightDirection),
})
}
onRimLightPositionChange={(rimLightPosition) => patchSettings({ rimLightPosition })}
/>
</div>
<LightingPreview
settings={settings}
sourceImage={appliedPreviewImage}
onLightPositionChange={(lightPosition) =>
patchSettings({
lightPosition,
mainLightDirection: positionToDirection(lightPosition, settings.mainLightDirection),
})
}
onRimLightPositionChange={(rimLightPosition) => patchSettings({ rimLightPosition })}
/>
<button
type="button"
onClick={() => setSettings(DEFAULT_LIGHTING_SETTINGS)}
@ -1136,8 +1274,9 @@ export function LightingEffectModal({
type="checkbox"
checked={settings.smartMode}
onChange={(event) => patchSettings({ smartMode: event.target.checked })}
className="h-4 w-4 accent-neutral-200"
className="peer sr-only"
/>
<span className="relative h-4 w-8 rounded-full bg-neutral-700 transition-colors after:absolute after:left-0.5 after:top-1/2 after:h-3 after:w-3 after:-translate-y-1/2 after:rounded-full after:bg-neutral-100 after:transition-transform peer-checked:bg-neutral-100 peer-checked:after:translate-x-[16px] peer-checked:after:bg-neutral-950" />
</label>
</div>
@ -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="打光亮度"
/>
<span className="w-12 rounded-md border border-neutral-700 px-2 py-1 text-center text-xs text-neutral-400">
<span className="w-16 rounded-lg border border-neutral-700/70 bg-neutral-950/40 px-2 py-1.5 text-center text-xs text-neutral-500">
{settings.brightness}%
</span>
</div>
@ -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({
</div>
{settings.smartMode && (
<div className="space-y-4 border-t border-neutral-800 pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
<div className="space-y-3 border-t border-neutral-800 pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
<div className="text-sm font-semibold text-neutral-100"></div>
<div className="grid grid-cols-[1fr_64px] gap-3">
<div className="grid grid-cols-[1fr_60px] gap-3">
<textarea
value={settings.smartPrompt}
onChange={(event) => patchSettings({ smartPrompt: event.target.value })}
placeholder="简单描述你想实现的打光效果,或者情绪风格"
className="h-20 resize-none rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 text-xs leading-5 text-neutral-100 outline-none placeholder:text-neutral-500 focus:border-neutral-500"
className="h-20 resize-none rounded-lg border border-neutral-700/80 bg-neutral-950/20 px-3 py-2 text-xs leading-5 text-neutral-100 outline-none placeholder:text-neutral-500 focus:border-neutral-500"
/>
<button
type="button"
onClick={() => referenceInputRef.current?.click()}
className="relative h-20 w-20 shrink-0 overflow-hidden rounded-lg border border-dashed border-neutral-700 bg-neutral-900 text-xs text-neutral-400 transition-colors hover:border-neutral-500 hover:text-neutral-100"
className="relative h-20 w-[60px] shrink-0 overflow-hidden rounded-lg border border-dashed border-neutral-700 bg-neutral-950/20 text-xs text-neutral-400 transition-colors hover:border-neutral-500 hover:text-neutral-100"
>
{settings.referenceImage ? (
<>
@ -1275,12 +1414,12 @@ export function LightingEffectModal({
selectedPresetId: preset.id,
})
}
className={`relative h-16 overflow-hidden rounded-lg border text-xs font-semibold text-white shadow-inner transition-transform hover:scale-[1.02] ${
selectedPreset?.id === preset.id ? "border-white" : "border-neutral-700"
className={`relative h-16 overflow-hidden rounded-lg border text-xs font-semibold text-white shadow-inner transition-transform hover:-translate-y-0.5 ${
selectedPreset?.id === preset.id ? "border-white shadow-[0_0_0_1px_rgba(255,255,255,0.22)]" : "border-neutral-700"
}`}
style={{ background: preset.thumbnail }}
>
<span className="absolute inset-x-0 bottom-0 bg-black/45 px-1 py-1">{preset.label}</span>
<LightingPresetThumbnail preset={preset} />
<span className="absolute inset-x-0 bottom-0 bg-black/50 px-1 py-1 text-[11px] leading-4">{preset.label}</span>
</button>
))}
</div>

50
src/utils/__tests__/lighting.test.ts

@ -1,11 +1,55 @@
import { describe, expect, it } from "vitest";
import { buildLightingPrompt, buildOptimizedLightingPrompt, DEFAULT_LIGHTING_SETTINGS, isSmartFreeMode } from "@/utils/lighting";
import {
buildLightingPrompt,
buildOptimizedLightingPrompt,
DEFAULT_LIGHTING_SETTINGS,
LIGHTING_PRESETS,
isSmartFreeMode,
} from "@/utils/lighting";
const GRID_LATITUDES = [45, 0, -45] as const;
const GRID_LONGITUDES = [0, 45, 90, 135, 180, 225, 270, 315] as const;
function degreesToRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
function gridPosition(latitude: number, longitude: number) {
const lat = degreesToRadians(latitude);
const lon = degreesToRadians(longitude);
const horizontalRadius = Math.cos(lat);
return {
x: Math.sin(lon) * horizontalRadius,
y: -Math.sin(lat),
z: Math.cos(lon) * horizontalRadius,
};
}
const LIGHTING_GRID_POINTS = [
...GRID_LATITUDES.flatMap((latitude) => GRID_LONGITUDES.map((longitude) => gridPosition(latitude, longitude))),
{ x: 0, y: -1, z: 0 },
{ x: 0, y: 1, z: 0 },
];
function pointDistance(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }) {
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
}
describe("lighting utilities", () => {
it("does not build a prompt when lighting is disabled", () => {
expect(buildLightingPrompt(DEFAULT_LIGHTING_SETTINGS)).toBe("");
});
it("keeps preset light positions on the preview latitude-longitude grid", () => {
for (const preset of LIGHTING_PRESETS) {
const position = preset.settings.lightPosition ?? DEFAULT_LIGHTING_SETTINGS.lightPosition;
const distanceToGrid = Math.min(...LIGHTING_GRID_POINTS.map((point) => pointDistance(position, point)));
expect(distanceToGrid).toBeLessThan(0.02);
}
});
it("builds a parametric prompt when smart mode has a preset selected", () => {
const prompt = buildLightingPrompt({
...DEFAULT_LIGHTING_SETTINGS,
@ -13,7 +57,7 @@ describe("lighting utilities", () => {
brightness: 62,
color: "#ffcc88",
mainLightDirection: "left",
lightPosition: { x: -0.58, y: -0.58, z: 0.58 },
lightPosition: { x: -0.5, y: -0.71, z: 0.5 },
beamAngle: -45,
rimLight: true,
smartMode: true,
@ -24,7 +68,7 @@ describe("lighting utilities", () => {
expect(prompt).toContain("left side key light");
expect(prompt).toContain("62% brightness");
expect(prompt).toContain("#ffcc88");
expect(prompt).toContain("x=-0.58");
expect(prompt).toContain("x=-0.50");
expect(prompt).toContain("-45 degree beam angle");
expect(prompt).toContain("add a second rim light source directly behind the subject");
expect(prompt).toContain("rim light source vector x=0.00");

4
src/utils/lighting.ts

@ -66,7 +66,7 @@ export const LIGHTING_PRESETS: LightingPreset[] = [
brightness: 50,
color: "#ffffff",
mainLightDirection: "left",
lightPosition: { x: -0.58, y: -0.58, z: 0.58 }, // 左上方 45°,经典三角光位
lightPosition: { x: -0.5, y: -0.71, z: 0.5 }, // 左上方 45°,吸附到经纬球交点
beamAngle: 45,
smartPrompt: "",
},
@ -83,7 +83,7 @@ export const LIGHTING_PRESETS: LightingPreset[] = [
lightPosition: { x: 0, y: 0, z: 1 },
beamAngle: 45,
rimLight: false,
smartPrompt: "《银翼杀手2045》同款青紫高反差打光,只调整光影",
smartPrompt: "让画面拥有《银翼杀手2045》同款打光,极简背景",
},
},
{

Loading…
Cancel
Save