|
|
|
@ -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> |
|
|
|
|