Browse Source

Refine lighting preview and relight queue

TEST-x
xuzhijie 2 months ago
parent
commit
5b15273aaa
  1. 70
      src/components/WorkflowCanvas.tsx
  2. 144
      src/components/__tests__/WorkflowCanvas.test.tsx
  3. 36
      src/components/lighting/LightingEffectModal.test.tsx
  4. 149
      src/components/lighting/LightingEffectModal.tsx
  5. 34
      src/utils/__tests__/lighting.test.ts
  6. 27
      src/utils/lighting.ts

70
src/components/WorkflowCanvas.tsx

@ -383,6 +383,7 @@ export function WorkflowCanvas() {
sourceImage: string; sourceImage: string;
settings: LightingSettings | null; settings: LightingSettings | null;
} | null>(null); } | null>(null);
const [queuedLightingNodeIds, setQueuedLightingNodeIds] = useState<string[]>([]);
// Fallback model picker state // Fallback model picker state
const [fallbackDialogState, setFallbackDialogState] = useState< const [fallbackDialogState, setFallbackDialogState] = useState<
@ -412,6 +413,14 @@ export function WorkflowCanvas() {
return unsubscribe; return unsubscribe;
}, []); }, []);
useEffect(() => {
if (isRunning || queuedLightingNodeIds.length === 0) return;
const [nextNodeId, ...remainingNodeIds] = queuedLightingNodeIds;
setQueuedLightingNodeIds(remainingNodeIds);
void regenerateNode(nextNodeId);
}, [isRunning, queuedLightingNodeIds, regenerateNode]);
// Detect if canvas is empty for showing quickstart // Detect if canvas is empty for showing quickstart
const isCanvasEmpty = nodes.length === 0; const isCanvasEmpty = nodes.length === 0;
@ -1260,28 +1269,32 @@ export function WorkflowCanvas() {
if (sourceNode.type === "imageInput") { if (sourceNode.type === "imageInput") {
updateNodeData(sourceNode.id, { lighting: settings } as Partial<ImageInputNodeData>); updateNodeData(sourceNode.id, { lighting: settings } as Partial<ImageInputNodeData>);
if (!isRunning) { const downstreamImageNodes = edges
const downstreamImageNode = edges .filter((edge) => edge.source === sourceNode.id && !edge.data?.isLoop)
.filter((edge) => edge.source === sourceNode.id && !edge.data?.isLoop) .map((edge) => nodes.find((node) => node.id === edge.target))
.map((edge) => nodes.find((node) => node.id === edge.target)) .filter((node): node is Node => node?.type === "nanoBanana");
.find((node) => node?.type === "nanoBanana"); const reusableDownstreamNode = downstreamImageNodes.find((node) => {
const data = node.data as NanoBananaNodeData;
return !data.outputImage && data.status !== "loading";
});
if (downstreamImageNode) { if (reusableDownstreamNode) {
targetNodeId = downstreamImageNode.id; updateNodeData(reusableDownstreamNode.id, { lighting: settings } as Partial<NanoBananaNodeData>);
} else { targetNodeId = reusableDownstreamNode.id;
// No downstream nanoBanana; auto-create one. } else {
const sourceW = sourceNode.measured?.width ?? 300; // Existing generated lighting results are preserved; create a fresh branch for each new relight.
const genX = sourceNode.position.x + sourceW + 160; const sourceW = sourceNode.measured?.width ?? 300;
const genY = sourceNode.position.y; const genX = sourceNode.position.x + sourceW + 160;
const genNodeId = addNode("nanoBanana", { x: genX, y: genY }); const genY = sourceNode.position.y;
onConnect({ const genNodeId = addNode("nanoBanana", { x: genX, y: genY });
source: sourceNode.id, updateNodeData(genNodeId, { lighting: settings } as Partial<NanoBananaNodeData>);
sourceHandle: "image", onConnect({
target: genNodeId, source: sourceNode.id,
targetHandle: "image", sourceHandle: "image",
}); target: genNodeId,
targetNodeId = genNodeId; targetHandle: "image",
} });
targetNodeId = genNodeId;
} }
} else if (sourceNode.type === "nanoBanana") { } else if (sourceNode.type === "nanoBanana") {
// nanoBanana 打光:查找下游是否已有 nanoBanana 节点,没有则自动创建 // nanoBanana 打光:查找下游是否已有 nanoBanana 节点,没有则自动创建
@ -1292,10 +1305,8 @@ export function WorkflowCanvas() {
if (downstreamImageNode) { if (downstreamImageNode) {
updateNodeData(downstreamImageNode.id, { lighting: settings } as Partial<NanoBananaNodeData>); updateNodeData(downstreamImageNode.id, { lighting: settings } as Partial<NanoBananaNodeData>);
if (!isRunning) { targetNodeId = downstreamImageNode.id;
targetNodeId = downstreamImageNode.id; } else {
}
} else if (!isRunning) {
// 没有下游 nanoBanana,自动创建一个并连接 // 没有下游 nanoBanana,自动创建一个并连接
const sourceW = sourceNode.measured?.width ?? 300; const sourceW = sourceNode.measured?.width ?? 300;
const genX = sourceNode.position.x + sourceW + 160; const genX = sourceNode.position.x + sourceW + 160;
@ -1315,7 +1326,14 @@ export function WorkflowCanvas() {
setLightingEditor(null); setLightingEditor(null);
if (targetNodeId) { if (targetNodeId) {
await regenerateNode(targetNodeId); if (isRunning) {
updateNodeData(targetNodeId, { status: "loading", error: null } as Partial<NanoBananaNodeData>);
setQueuedLightingNodeIds((current) =>
current.includes(targetNodeId) ? current : [...current, targetNodeId]
);
} else {
await regenerateNode(targetNodeId);
}
} }
}, },
[addNode, edges, getNodeById, isRunning, lightingEditor, nodes, onConnect, regenerateNode, updateNodeData] [addNode, edges, getNodeById, isRunning, lightingEditor, nodes, onConnect, regenerateNode, updateNodeData]

144
src/components/__tests__/WorkflowCanvas.test.tsx

@ -31,6 +31,18 @@ vi.mock("@/store/workflowStore", () => ({
} }
return mockUseWorkflowStore((s: unknown) => s); return mockUseWorkflowStore((s: unknown) => s);
}, },
useProviderApiKeys: () => ({
geminiApiKey: null,
newApiWGApiKey: null,
newApiWGBaseUrl: null,
replicateApiKey: null,
falApiKey: null,
kieApiKey: null,
wavespeedApiKey: null,
replicateEnabled: false,
kieEnabled: false,
}),
saveNanoBananaDefaults: vi.fn(),
})); }));
// Mock useReactFlow // Mock useReactFlow
@ -169,6 +181,7 @@ const createDefaultState = (overrides = {}) => ({
addToGlobalHistory: mockAddToGlobalHistory, addToGlobalHistory: mockAddToGlobalHistory,
setNodeGroupId: mockSetNodeGroupId, setNodeGroupId: mockSetNodeGroupId,
executeWorkflow: mockExecuteWorkflow, executeWorkflow: mockExecuteWorkflow,
isRunning: false,
isModalOpen: false, isModalOpen: false,
showQuickstart: false, showQuickstart: false,
setShowQuickstart: mockSetShowQuickstart, setShowQuickstart: mockSetShowQuickstart,
@ -369,6 +382,137 @@ describe("WorkflowCanvas", () => {
targetHandle: "image", targetHandle: "image",
}); });
}); });
it("should create a fresh generated image node when relighting an image input that already has an output", async () => {
const sourceNode = createMockNode("image-1", "imageInput", {
selected: true,
measured: { width: 300, height: 220 },
data: { image: "data:image/png;base64,uploaded" },
});
const existingGeneratedNode = createMockNode("generated-1", "nanoBanana", {
hidden: true,
position: { x: 560, y: 100 },
data: { outputImage: "data:image/png;base64,lit-once" },
});
const existingEdge = {
id: "edge-image-generated-1",
source: "image-1",
sourceHandle: "image",
target: "generated-1",
targetHandle: "image",
data: {},
};
mockAddNode.mockReturnValueOnce("relight-node-id");
mockGetNodeById.mockImplementation((id) => (id === "image-1" ? sourceNode : undefined));
mockRegenerateNode.mockResolvedValue(undefined);
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [sourceNode, existingGeneratedNode],
edges: [existingEdge],
}));
});
render(
<TestWrapper>
<WorkflowCanvas />
</TestWrapper>
);
const quickAddButton = screen.getAllByRole("button", { name: "Add connected node" })[0];
fireEvent.pointerDown(quickAddButton, {
clientX: 418,
clientY: 210,
});
fireEvent.pointerUp(quickAddButton, {
clientX: 418,
clientY: 210,
});
await screen.findByTestId("connection-drop-menu");
fireEvent.click(screen.getByText("Open Image Lighting"));
fireEvent.click(await screen.findByText("Apply Lighting"));
await waitFor(() => {
expect(mockRegenerateNode).toHaveBeenCalledWith("relight-node-id");
});
expect(mockRegenerateNode).not.toHaveBeenCalledWith("generated-1");
expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", { x: 560, y: 100 });
expect(mockUpdateNodeData).toHaveBeenCalledWith("relight-node-id", {
lighting: expect.objectContaining({ enabled: true, brightness: 72 }),
});
expect(mockOnConnect).toHaveBeenCalledWith({
source: "image-1",
sourceHandle: "image",
target: "relight-node-id",
targetHandle: "image",
});
});
it("should queue image lighting generation while another generation is running", async () => {
const sourceNode = createMockNode("image-1", "imageInput", {
selected: true,
measured: { width: 300, height: 220 },
data: { image: "data:image/png;base64,uploaded" },
});
mockAddNode.mockReturnValueOnce("queued-lighting-node");
mockGetNodeById.mockImplementation((id) => (id === "image-1" ? sourceNode : undefined));
mockRegenerateNode.mockResolvedValue(undefined);
let storeState = createDefaultState({
nodes: [sourceNode],
isRunning: true,
});
mockUseWorkflowStore.mockImplementation((selector) => selector(storeState));
const { rerender } = render(
<TestWrapper>
<WorkflowCanvas />
</TestWrapper>
);
const quickAddButton = screen.getByRole("button", { name: "Add connected node" });
fireEvent.pointerDown(quickAddButton, {
clientX: 418,
clientY: 210,
});
fireEvent.pointerUp(quickAddButton, {
clientX: 418,
clientY: 210,
});
await screen.findByTestId("connection-drop-menu");
fireEvent.click(screen.getByText("Open Image Lighting"));
fireEvent.click(await screen.findByText("Apply Lighting"));
await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("queued-lighting-node", {
status: "loading",
error: null,
});
});
expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", { x: 560, y: 100 });
expect(mockOnConnect).toHaveBeenCalledWith({
source: "image-1",
sourceHandle: "image",
target: "queued-lighting-node",
targetHandle: "image",
});
expect(mockRegenerateNode).not.toHaveBeenCalled();
storeState = {
...storeState,
isRunning: false,
};
rerender(
<TestWrapper>
<WorkflowCanvas />
</TestWrapper>
);
await waitFor(() => {
expect(mockRegenerateNode).toHaveBeenCalledWith("queued-lighting-node");
});
});
}); });
describe("Welcome Modal", () => { describe("Welcome Modal", () => {

36
src/components/lighting/LightingEffectModal.test.tsx

@ -33,6 +33,10 @@ function renderLightingModal(overrides: Partial<LightingSettings>) {
return { ...result, onApply }; return { ...result, onApply };
} }
function extractPathTopValues(pathData: string): number[] {
return Array.from(pathData.matchAll(/[ML] [-\d.]+ ([-\d.]+)/g), (match) => Number(match[1]));
}
describe("LightingEffectModal preview", () => { describe("LightingEffectModal preview", () => {
it("keeps the source image flat in front view", () => { it("keeps the source image flat in front view", () => {
renderLightingModal({ renderLightingModal({
@ -131,9 +135,37 @@ describe("LightingEffectModal preview", () => {
expect(poles).toHaveLength(2); expect(poles).toHaveLength(2);
expect(northPole?.getAttribute("cx")).toBe("50"); expect(northPole?.getAttribute("cx")).toBe("50");
expect(Number.parseFloat(northPole?.getAttribute("cy") ?? "")).toBeCloseTo(7); expect(Number.parseFloat(northPole?.getAttribute("cy") ?? "")).toBeCloseTo(4);
expect(southPole?.getAttribute("cx")).toBe("50"); expect(southPole?.getAttribute("cx")).toBe("50");
expect(Number.parseFloat(southPole?.getAttribute("cy") ?? "")).toBeCloseTo(93); expect(Number.parseFloat(southPole?.getAttribute("cy") ?? "")).toBeCloseTo(96);
});
it("draws front view globe lines with front and back surface depth", () => {
renderLightingModal({
viewMode: "front",
});
const lines = screen.getAllByTestId("lighting-globe-line");
const segments = screen.getAllByTestId("lighting-globe-segment");
const frontSegments = segments.filter((segment) => segment.getAttribute("data-hidden") === "false");
const backSegments = segments.filter((segment) => segment.getAttribute("data-hidden") === "true");
const backSegment = backSegments[0];
const longitudeIds = lines
.filter((line) => line.getAttribute("data-line-kind") === "longitude")
.map((line) => line.getAttribute("data-line-id"));
const upperLatitude = lines.find((line) => line.getAttribute("data-line-id") === "latitude-p45");
const upperLatitudeTops = Array.from(
upperLatitude?.querySelectorAll('[data-testid="lighting-globe-segment"]') ?? []
).flatMap((segment) => extractPathTopValues(segment.getAttribute("d") ?? ""));
expect(lines).toHaveLength(6);
expect(lines.filter((line) => line.getAttribute("data-line-kind") === "latitude")).toHaveLength(3);
expect(longitudeIds).toEqual(["longitude-0", "longitude-45", "longitude-315"]);
expect(frontSegments.length).toBeGreaterThan(0);
expect(backSegments.length).toBeGreaterThan(0);
expect(backSegment.getAttribute("stroke-dasharray")).toBe("1.6 2.6");
expect(Number.parseFloat(backSegment.getAttribute("opacity") ?? "1")).toBeLessThan(0.5);
expect(Math.max(...upperLatitudeTops) - Math.min(...upperLatitudeTops)).toBeGreaterThan(2);
}); });
it("preserves the loaded source image aspect ratio in the preview subject", () => { it("preserves the loaded source image aspect ratio in the preview subject", () => {

149
src/components/lighting/LightingEffectModal.tsx

@ -319,8 +319,31 @@ interface GlobeLineSegment {
hidden: boolean; hidden: boolean;
} }
interface GlobeLineStyle {
stroke: string;
strokeWidth: number;
opacity: number;
strokeDasharray?: string;
}
// Sphere projection radius in SVG units (viewBox 0-100) // Sphere projection radius in SVG units (viewBox 0-100)
const SPHERE_RADIUS = 43; const SPHERE_RADIUS = 43;
const FRONT_SPHERE_RADIUS = 46;
const FRONT_DEPTH_PERSPECTIVE = 0.16;
const FRONT_VISIBLE_LONGITUDES = new Set<number>([315, 0, 45]);
function projectViewPosition(
view: LightingPosition,
viewMode: LightingViewMode
): PreviewPoint & { depth: number } {
const scale = viewMode === "front" ? 1 + view.z * FRONT_DEPTH_PERSPECTIVE : 1;
const radius = viewMode === "front" ? FRONT_SPHERE_RADIUS : SPHERE_RADIUS;
return {
left: 50 + view.x * radius * scale,
top: 50 + view.y * radius * scale,
depth: view.z,
};
}
/** /**
* Project a lat/lon on the unit sphere to SVG coordinates via the same * Project a lat/lon on the unit sphere to SVG coordinates via the same
@ -330,41 +353,24 @@ const SPHERE_RADIUS = 43;
function projectSpherePoint(latitude: number, longitude: number, viewMode: LightingViewMode): PreviewPoint & { depth: number } { function projectSpherePoint(latitude: number, longitude: number, viewMode: LightingViewMode): PreviewPoint & { depth: number } {
const pos = latLonToPosition(latitude, longitude); const pos = latLonToPosition(latitude, longitude);
const view = positionToView(pos, viewMode); const view = positionToView(pos, viewMode);
return { return projectViewPosition(view, viewMode);
left: 50 + view.x * SPHERE_RADIUS,
top: 50 + view.y * SPHERE_RADIUS,
depth: view.z,
};
} }
/** Project a 3D LightingPosition on the sphere to SVG coordinates. */ /** Project a 3D LightingPosition on the sphere to SVG coordinates. */
function projectPositionToSphere(position: LightingPosition, viewMode: LightingViewMode): PreviewPoint & { depth: number } { function projectPositionToSphere(position: LightingPosition, viewMode: LightingViewMode): PreviewPoint & { depth: number } {
const view = positionToView(position, viewMode); const view = positionToView(position, viewMode);
return { return projectViewPosition(view, viewMode);
left: 50 + view.x * SPHERE_RADIUS,
top: 50 + view.y * SPHERE_RADIUS,
depth: view.z,
};
} }
const SAMPLES_PER_LINE = 72; const SAMPLES_PER_LINE = 72;
function pointsToPath(points: ReadonlyArray<{ left: number; top: number }>): string {
const parts = [`M ${formatCssNumber(points[0].left)} ${formatCssNumber(points[0].top)}`];
for (let i = 1; i < points.length; i++) {
parts.push(`L ${formatCssNumber(points[i].left)} ${formatCssNumber(points[i].top)}`);
}
const first = points[0];
parts.push(`L ${formatCssNumber(first.left)} ${formatCssNumber(first.top)}`);
return parts.join(" ");
}
/** /**
* Build SVG path data from projected 2D points. * Build SVG path data from projected 2D points.
* Splits into front-facing (depth >= 0, solid) and back-facing (depth < 0, dashed) segments. * Splits into front-facing (depth >= 0, solid) and back-facing (depth < 0, dashed) segments.
*/ */
function buildLineSegments( function buildLineSegments(
points: ReadonlyArray<{ left: number; top: number; depth: number }> points: ReadonlyArray<{ left: number; top: number; depth: number }>,
closed: boolean
): GlobeLineSegment[] { ): GlobeLineSegment[] {
if (points.length < 2) return []; if (points.length < 2) return [];
@ -400,22 +406,51 @@ function buildLineSegments(
} }
} }
// Close the loop back to the first point if (closed) {
const first = points[0]; const first = points[0];
const lastHidden = points[points.length - 1].depth < 0; const lastHidden = points[points.length - 1].depth < 0;
if (lastHidden === currentHidden) { if (lastHidden === currentHidden) {
currentD.push(`L ${formatCssNumber(first.left)} ${formatCssNumber(first.top)}`); currentD.push(`L ${formatCssNumber(first.left)} ${formatCssNumber(first.top)}`);
}
} }
flush(); flush();
return segments; return segments;
} }
function getGlobeLineStyle(segment: GlobeLineSegment, viewMode: LightingViewMode): GlobeLineStyle {
if (viewMode === "front") {
return segment.hidden
? {
stroke: "rgba(255,255,255,0.26)",
strokeWidth: 0.52,
opacity: 0.36,
strokeDasharray: "1.6 2.6",
}
: {
stroke: "rgba(255,255,255,0.48)",
strokeWidth: 0.62,
opacity: 0.72,
};
}
return segment.hidden
? {
stroke: "rgba(255,255,255,0.42)",
strokeWidth: 0.62,
opacity: 0.56,
strokeDasharray: "3 2.5",
}
: {
stroke: "rgba(255,255,255,0.5)",
strokeWidth: 0.68,
opacity: 0.85,
};
}
function buildVisualGlobeLines( function buildVisualGlobeLines(
viewMode: LightingViewMode viewMode: LightingViewMode
): Array<{ id: string; kind: "latitude" | "longitude"; segments: GlobeLineSegment[] }> { ): Array<{ id: string; kind: "latitude" | "longitude"; segments: GlobeLineSegment[] }> {
const isFront = viewMode === "front";
const latitudeLines = LATITUDE_DEGREES.map((latitude) => { const latitudeLines = LATITUDE_DEGREES.map((latitude) => {
const points: Array<{ left: number; top: number; depth: number }> = []; const points: Array<{ left: number; top: number; depth: number }> = [];
for (let i = 0; i < SAMPLES_PER_LINE; i++) { for (let i = 0; i < SAMPLES_PER_LINE; i++) {
@ -425,17 +460,15 @@ function buildVisualGlobeLines(
return { return {
id: `latitude-${latitudeId(latitude)}`, id: `latitude-${latitudeId(latitude)}`,
kind: "latitude" as const, kind: "latitude" as const,
segments: isFront segments: buildLineSegments(points, true),
? [{ path: pointsToPath(points), hidden: false }]
: buildLineSegments(points),
}; };
}); });
const longitudeLines = LONGITUDE_DEGREES.filter((longitude) => { const visibleLongitudes = viewMode === "front"
// In front view, 0° and 180° project as a straight vertical line through ? LONGITUDE_DEGREES.filter((longitude) => FRONT_VISIBLE_LONGITUDES.has(longitude))
// the center with no visible curvature — skip them. : LONGITUDE_DEGREES;
return !(isFront && (longitude === 0 || longitude === 180));
}).map((longitude) => { const longitudeLines = visibleLongitudes.map((longitude) => {
const points: Array<{ left: number; top: number; depth: number }> = []; const points: Array<{ left: number; top: number; depth: number }> = [];
for (let i = 0; i < SAMPLES_PER_LINE; i++) { for (let i = 0; i < SAMPLES_PER_LINE; i++) {
const latitude = -90 + (i / SAMPLES_PER_LINE) * 180; const latitude = -90 + (i / SAMPLES_PER_LINE) * 180;
@ -444,9 +477,7 @@ function buildVisualGlobeLines(
return { return {
id: `longitude-${longitude}`, id: `longitude-${longitude}`,
kind: "longitude" as const, kind: "longitude" as const,
segments: isFront segments: buildLineSegments(points, false),
? [{ path: pointsToPath(points), hidden: false }]
: buildLineSegments(points),
}; };
}); });
@ -714,19 +745,25 @@ function LightingPreview({
<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)]" /> <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"> <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) => ( {globeLines.map((line) => (
<g key={line.id} data-testid="lighting-globe-line" data-line-kind={line.kind}> <g key={line.id} data-testid="lighting-globe-line" data-line-id={line.id} data-line-kind={line.kind}>
{line.segments.map((segment, index) => ( {line.segments.map((segment, index) => {
<path const lineStyle = getGlobeLineStyle(segment, settings.viewMode);
key={`${line.id}-${index}`} return (
d={segment.path} <path
fill="none" key={`${line.id}-${index}`}
stroke="rgba(255,255,255,0.5)" d={segment.path}
strokeWidth={0.68} fill="none"
strokeDasharray={segment.hidden ? "3 2.5" : undefined} stroke={lineStyle.stroke}
opacity={segment.hidden ? 0.6 : 0.85} strokeWidth={lineStyle.strokeWidth}
vectorEffect="non-scaling-stroke" strokeDasharray={lineStyle.strokeDasharray}
/> strokeLinecap="round"
))} opacity={lineStyle.opacity}
vectorEffect="non-scaling-stroke"
data-testid="lighting-globe-segment"
data-hidden={segment.hidden ? "true" : "false"}
/>
);
})}
</g> </g>
))} ))}
{(() => { {(() => {
@ -756,18 +793,24 @@ function LightingPreview({
{LATITUDE_DEGREES.flatMap((latitude) => {LATITUDE_DEGREES.flatMap((latitude) =>
LONGITUDE_DEGREES.map((longitude) => { LONGITUDE_DEGREES.map((longitude) => {
const pt = projectSpherePoint(latitude, longitude, settings.viewMode); const pt = projectSpherePoint(latitude, longitude, settings.viewMode);
const isBackPoint = settings.viewMode === "front" && pt.depth < 0;
return ( return (
<circle <circle
key={`dot-lat-${latitude}-lon-${longitude}`} key={`dot-lat-${latitude}-lon-${longitude}`}
cx={pt.left} cx={pt.left}
cy={pt.top} cy={pt.top}
r={0.9} r={0.9}
fill="rgba(255,255,255,0.45)" fill={isBackPoint ? "rgba(255,255,255,0.24)" : "rgba(255,255,255,0.48)"}
opacity={isBackPoint ? 0.38 : 0.82}
/> />
); );
}) })
)} )}
</svg> </svg>
<div
className="pointer-events-none absolute inset-0 z-[3] rounded-full bg-[radial-gradient(circle_at_38%_28%,rgba(255,255,255,0.08),transparent_34%),radial-gradient(circle_at_50%_50%,transparent_56%,rgba(0,0,0,0.2)_80%,rgba(255,255,255,0.1)_100%)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07),inset_0_-16px_24px_rgba(0,0,0,0.18)]"
style={{ opacity: settings.viewMode === "front" ? 1 : 0.76 }}
/>
{MAIN_LIGHT_STOPS.map((stop) => { {MAIN_LIGHT_STOPS.map((stop) => {
const stopProjected = projectPositionToSphere(stop.lightPosition, settings.viewMode); const stopProjected = projectPositionToSphere(stop.lightPosition, settings.viewMode);

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

@ -50,6 +50,37 @@ describe("lighting utilities", () => {
} }
}); });
it("keeps blue backlight as a rear rim-light preset instead of a bottom uplight", () => {
const preset = LIGHTING_PRESETS.find((item) => item.id === "blue-backlight");
expect(preset).toBeDefined();
const settings = {
...DEFAULT_LIGHTING_SETTINGS,
enabled: true,
selectedPresetId: "blue-backlight",
...preset?.settings,
};
const prompt = buildLightingPrompt(settings);
expect(settings.mainLightDirection).toBe("back");
expect(settings.lightPosition).toEqual({ x: 0, y: 0.71, z: -0.71 });
expect(settings.beamAngle).toBe(90);
expect(prompt).toContain("back light");
expect(prompt).toContain("behind the subject");
expect(prompt).toContain("y=0.71");
expect(prompt).toContain("z=-0.71");
expect(prompt).toContain("low on the rear side of the subject");
expect(prompt).toContain("without drawing any lamp or emitter");
expect(prompt).toContain("electric-blue rim light");
expect(prompt).toContain("soft blue ground haze");
expect(prompt).toContain("no visible flashlight");
expect(prompt).toContain("no visible spotlight cone");
expect(prompt).toContain("no frontal blue spotlight");
expect(prompt).not.toContain("aim the light");
expect(prompt).not.toContain("focused flashlight-like beam");
expect(prompt).not.toContain("Place the main light below the subject");
});
it("builds a parametric prompt when smart mode has a preset selected", () => { it("builds a parametric prompt when smart mode has a preset selected", () => {
const prompt = buildLightingPrompt({ const prompt = buildLightingPrompt({
...DEFAULT_LIGHTING_SETTINGS, ...DEFAULT_LIGHTING_SETTINGS,
@ -69,7 +100,7 @@ describe("lighting utilities", () => {
expect(prompt).toContain("62% brightness"); expect(prompt).toContain("62% brightness");
expect(prompt).toContain("#ffcc88"); expect(prompt).toContain("#ffcc88");
expect(prompt).toContain("x=-0.50"); expect(prompt).toContain("x=-0.50");
expect(prompt).toContain("-45 degree beam angle"); expect(prompt).toContain("-45 degree light spread");
expect(prompt).toContain("add a second rim light source directly behind the subject"); expect(prompt).toContain("add a second rim light source directly behind the subject");
expect(prompt).toContain("rim light source vector x=0.00"); expect(prompt).toContain("rim light source vector x=0.00");
expect(prompt).toContain("z=-1.00"); expect(prompt).toContain("z=-1.00");
@ -145,6 +176,7 @@ describe("lighting utilities", () => {
expect(prompt).toContain("same exact person"); expect(prompt).toContain("same exact person");
expect(prompt).toContain("Preserve the original composition"); expect(prompt).toContain("Preserve the original composition");
expect(prompt).toContain("Only change illumination"); expect(prompt).toContain("Only change illumination");
expect(prompt).toContain("Do not add visible lighting equipment");
expect(prompt).toContain("lighting reference only"); expect(prompt).toContain("lighting reference only");
expect(prompt).toContain("Do not add or remove people or objects"); expect(prompt).toContain("Do not add or remove people or objects");
}); });

27
src/utils/lighting.ts

@ -46,13 +46,13 @@ export const LIGHTING_PRESETS: LightingPreset[] = [
id: "blue-backlight", id: "blue-backlight",
label: "蓝色逆光", label: "蓝色逆光",
thumbnail: "linear-gradient(135deg, #080b18 0%, #1646ff 60%, #dbe8ff 100%)", thumbnail: "linear-gradient(135deg, #080b18 0%, #1646ff 60%, #dbe8ff 100%)",
prompt: "blue backlight, cool rim glow, dramatic contrast", prompt: "dark nighttime blue backlight from the lower rear side behind the subjects, cool electric-blue rim light tracing silhouettes and hair edges, deep navy ambient exposure, soft blue ground haze, subtle readable front fill, no visible lamp, no visible flashlight, no visible spotlight cone, no frontal blue spotlight, no bottom uplight",
settings: { settings: {
brightness: 50, brightness: 35,
color: "#3438ff", color: "#2563ff",
mainLightDirection: "bottom", mainLightDirection: "back",
lightPosition: { x: 0, y: 1, z: 0 }, lightPosition: { x: 0, y: 0.71, z: -0.71 },
beamAngle: 45, beamAngle: 90,
rimLight: false, rimLight: false,
smartPrompt: "", smartPrompt: "",
}, },
@ -185,23 +185,29 @@ function brightnessInstruction(value: number): string {
/** 3D 坐标 → 可读的位置描述(左/右/中、上/下/平、前/后/面上) */ /** 3D 坐标 → 可读的位置描述(左/右/中、上/下/平、前/后/面上) */
function positionInstruction(position: LightingPosition, label = "light source"): string { function positionInstruction(position: LightingPosition, label = "light source"): string {
const horizontal = position.x <= -0.25 ? "camera-left" : position.x >= 0.25 ? "camera-right" : "centered"; const horizontal = position.x <= -0.25 ? "camera-left" : position.x >= 0.25 ? "camera-right" : "centered";
const vertical = position.y <= -0.25 ? "above the subject" : position.y >= 0.25 ? "below the subject" : "near eye level";
const depth = position.z <= -0.25 ? "behind the subject" : position.z >= 0.25 ? "in front of the subject" : "on the subject plane"; const depth = position.z <= -0.25 ? "behind the subject" : position.z >= 0.25 ? "in front of the subject" : "on the subject plane";
const vertical = position.y <= -0.25
? "above the subject"
: position.y >= 0.25
? position.z <= -0.25
? "low on the rear side of the subject, not below the subject"
: "below the subject"
: "near eye level";
return [ return [
`${label} vector x=${position.x.toFixed(2)}`, `${label} vector x=${position.x.toFixed(2)}`,
`y=${position.y.toFixed(2)}`, `y=${position.y.toFixed(2)}`,
`z=${position.z.toFixed(2)}`, `z=${position.z.toFixed(2)}`,
`${horizontal}, ${vertical}, ${depth}`, `${horizontal}, ${vertical}, ${depth}`,
"aim the light directly at the center of the source image", "apply illumination toward the center of the source image without drawing any lamp or emitter",
].join(", "); ].join(", ");
} }
/** 光束角度 → 聚光/泛光 + 正/负倾斜描述 */ /** 光束角度 → 聚光/泛光 + 正/负倾斜描述 */
function beamAngleInstruction(angle: LightingBeamAngle): string { function beamAngleInstruction(angle: LightingBeamAngle): string {
const width = Math.abs(angle) === 45 ? "focused flashlight-like beam" : "wide studio softbox spread"; const width = Math.abs(angle) === 45 ? "focused studio key-light falloff" : "wide studio soft light spread";
const tilt = angle < 0 ? "negative tilt angle" : "positive tilt angle"; const tilt = angle < 0 ? "negative tilt angle" : "positive tilt angle";
return `${angle} degree beam angle, ${width}, ${tilt}`; return `${angle} degree light spread, ${width}, ${tilt}`;
} }
// ============================================================================ // ============================================================================
@ -342,6 +348,7 @@ export function buildOptimizedLightingPrompt(
"The output must show the same exact person from the source image: identical face identity, facial features, age, expression, hair, body shape, hands, pose, clothing, accessories, and camera/phone.", "The output must show the same exact person from the source image: identical face identity, facial features, age, expression, hair, body shape, hands, pose, clothing, accessories, and camera/phone.",
"Preserve the original composition, camera angle, crop, background, object layout, readable text overlays, and image style.", "Preserve the original composition, camera angle, crop, background, object layout, readable text overlays, and image style.",
"Only change illumination: light direction, brightness, color temperature, shadows, highlights, reflections, and ambient lighting mood required by the lighting plan.", "Only change illumination: light direction, brightness, color temperature, shadows, highlights, reflections, and ambient lighting mood required by the lighting plan.",
"Do not add visible lighting equipment, lamps, flashlights, spotlights, projectors, light cones, beam cones, or glowing props; the lighting must appear only as illumination on the existing scene.",
"Treat any preset or style wording as a lighting reference only, not permission to redesign the person, outfit, background, pose, scene, or photographic composition.", "Treat any preset or style wording as a lighting reference only, not permission to redesign the person, outfit, background, pose, scene, or photographic composition.",
"Do not add or remove people or objects, do not beautify or re-age the person, do not replace the face, do not change hairstyle or clothing, do not crop the subject, and do not create a different scene.", "Do not add or remove people or objects, do not beautify or re-age the person, do not replace the face, do not change hairstyle or clothing, do not crop the subject, and do not create a different scene.",
"Do not hallucinate, invent, or complete any details that are not visible in the source image — if eyes, features, or areas are hidden, closed, covered, or obscured in the original, they must remain exactly as they are. Only adjust how light and shadow fall on the existing content.", "Do not hallucinate, invent, or complete any details that are not visible in the source image — if eyes, features, or areas are hidden, closed, covered, or obscured in the original, they must remain exactly as they are. Only adjust how light and shadow fall on the existing content.",

Loading…
Cancel
Save