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

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

@ -31,6 +31,18 @@ vi.mock("@/store/workflowStore", () => ({
}
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
@ -169,6 +181,7 @@ const createDefaultState = (overrides = {}) => ({
addToGlobalHistory: mockAddToGlobalHistory,
setNodeGroupId: mockSetNodeGroupId,
executeWorkflow: mockExecuteWorkflow,
isRunning: false,
isModalOpen: false,
showQuickstart: false,
setShowQuickstart: mockSetShowQuickstart,
@ -369,6 +382,137 @@ describe("WorkflowCanvas", () => {
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", () => {

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

@ -33,6 +33,10 @@ function renderLightingModal(overrides: Partial<LightingSettings>) {
return { ...result, onApply };
}
function extractPathTopValues(pathData: string): number[] {
return Array.from(pathData.matchAll(/[ML] [-\d.]+ ([-\d.]+)/g), (match) => Number(match[1]));
}
describe("LightingEffectModal preview", () => {
it("keeps the source image flat in front view", () => {
renderLightingModal({
@ -131,9 +135,37 @@ describe("LightingEffectModal preview", () => {
expect(poles).toHaveLength(2);
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(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", () => {

149
src/components/lighting/LightingEffectModal.tsx

@ -319,8 +319,31 @@ interface GlobeLineSegment {
hidden: boolean;
}
interface GlobeLineStyle {
stroke: string;
strokeWidth: number;
opacity: number;
strokeDasharray?: string;
}
// Sphere projection radius in SVG units (viewBox 0-100)
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
@ -330,41 +353,24 @@ const SPHERE_RADIUS = 43;
function projectSpherePoint(latitude: number, longitude: number, viewMode: LightingViewMode): PreviewPoint & { depth: number } {
const pos = latLonToPosition(latitude, longitude);
const view = positionToView(pos, viewMode);
return {
left: 50 + view.x * SPHERE_RADIUS,
top: 50 + view.y * SPHERE_RADIUS,
depth: view.z,
};
return projectViewPosition(view, viewMode);
}
/** Project a 3D LightingPosition on the sphere to SVG coordinates. */
function projectPositionToSphere(position: LightingPosition, viewMode: LightingViewMode): PreviewPoint & { depth: number } {
const view = positionToView(position, viewMode);
return {
left: 50 + view.x * SPHERE_RADIUS,
top: 50 + view.y * SPHERE_RADIUS,
depth: view.z,
};
return projectViewPosition(view, viewMode);
}
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.
* Splits into front-facing (depth >= 0, solid) and back-facing (depth < 0, dashed) segments.
*/
function buildLineSegments(
points: ReadonlyArray<{ left: number; top: number; depth: number }>
points: ReadonlyArray<{ left: number; top: number; depth: number }>,
closed: boolean
): GlobeLineSegment[] {
if (points.length < 2) return [];
@ -400,22 +406,51 @@ function buildLineSegments(
}
}
// Close the loop back to the first point
const first = points[0];
const lastHidden = points[points.length - 1].depth < 0;
if (lastHidden === currentHidden) {
currentD.push(`L ${formatCssNumber(first.left)} ${formatCssNumber(first.top)}`);
if (closed) {
const first = points[0];
const lastHidden = points[points.length - 1].depth < 0;
if (lastHidden === currentHidden) {
currentD.push(`L ${formatCssNumber(first.left)} ${formatCssNumber(first.top)}`);
}
}
flush();
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(
viewMode: LightingViewMode
): Array<{ id: string; kind: "latitude" | "longitude"; segments: GlobeLineSegment[] }> {
const isFront = viewMode === "front";
const latitudeLines = LATITUDE_DEGREES.map((latitude) => {
const points: Array<{ left: number; top: number; depth: number }> = [];
for (let i = 0; i < SAMPLES_PER_LINE; i++) {
@ -425,17 +460,15 @@ function buildVisualGlobeLines(
return {
id: `latitude-${latitudeId(latitude)}`,
kind: "latitude" as const,
segments: isFront
? [{ path: pointsToPath(points), hidden: false }]
: buildLineSegments(points),
segments: buildLineSegments(points, true),
};
});
const longitudeLines = LONGITUDE_DEGREES.filter((longitude) => {
// In front view, 0° and 180° project as a straight vertical line through
// the center with no visible curvature — skip them.
return !(isFront && (longitude === 0 || longitude === 180));
}).map((longitude) => {
const visibleLongitudes = viewMode === "front"
? LONGITUDE_DEGREES.filter((longitude) => FRONT_VISIBLE_LONGITUDES.has(longitude))
: LONGITUDE_DEGREES;
const longitudeLines = visibleLongitudes.map((longitude) => {
const points: Array<{ left: number; top: number; depth: number }> = [];
for (let i = 0; i < SAMPLES_PER_LINE; i++) {
const latitude = -90 + (i / SAMPLES_PER_LINE) * 180;
@ -444,9 +477,7 @@ function buildVisualGlobeLines(
return {
id: `longitude-${longitude}`,
kind: "longitude" as const,
segments: isFront
? [{ path: pointsToPath(points), hidden: false }]
: buildLineSegments(points),
segments: buildLineSegments(points, false),
};
});
@ -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)]" />
<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}>
{line.segments.map((segment, index) => (
<path
key={`${line.id}-${index}`}
d={segment.path}
fill="none"
stroke="rgba(255,255,255,0.5)"
strokeWidth={0.68}
strokeDasharray={segment.hidden ? "3 2.5" : undefined}
opacity={segment.hidden ? 0.6 : 0.85}
vectorEffect="non-scaling-stroke"
/>
))}
<g key={line.id} data-testid="lighting-globe-line" data-line-id={line.id} data-line-kind={line.kind}>
{line.segments.map((segment, index) => {
const lineStyle = getGlobeLineStyle(segment, settings.viewMode);
return (
<path
key={`${line.id}-${index}`}
d={segment.path}
fill="none"
stroke={lineStyle.stroke}
strokeWidth={lineStyle.strokeWidth}
strokeDasharray={lineStyle.strokeDasharray}
strokeLinecap="round"
opacity={lineStyle.opacity}
vectorEffect="non-scaling-stroke"
data-testid="lighting-globe-segment"
data-hidden={segment.hidden ? "true" : "false"}
/>
);
})}
</g>
))}
{(() => {
@ -756,18 +793,24 @@ function LightingPreview({
{LATITUDE_DEGREES.flatMap((latitude) =>
LONGITUDE_DEGREES.map((longitude) => {
const pt = projectSpherePoint(latitude, longitude, settings.viewMode);
const isBackPoint = settings.viewMode === "front" && pt.depth < 0;
return (
<circle
key={`dot-lat-${latitude}-lon-${longitude}`}
cx={pt.left}
cy={pt.top}
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>
<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) => {
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", () => {
const prompt = buildLightingPrompt({
...DEFAULT_LIGHTING_SETTINGS,
@ -69,7 +100,7 @@ describe("lighting utilities", () => {
expect(prompt).toContain("62% brightness");
expect(prompt).toContain("#ffcc88");
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("rim light source vector x=0.00");
expect(prompt).toContain("z=-1.00");
@ -145,6 +176,7 @@ describe("lighting utilities", () => {
expect(prompt).toContain("same exact person");
expect(prompt).toContain("Preserve the original composition");
expect(prompt).toContain("Only change illumination");
expect(prompt).toContain("Do not add visible lighting equipment");
expect(prompt).toContain("lighting reference only");
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",
label: "蓝色逆光",
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: {
brightness: 50,
color: "#3438ff",
mainLightDirection: "bottom",
lightPosition: { x: 0, y: 1, z: 0 },
beamAngle: 45,
brightness: 35,
color: "#2563ff",
mainLightDirection: "back",
lightPosition: { x: 0, y: 0.71, z: -0.71 },
beamAngle: 90,
rimLight: false,
smartPrompt: "",
},
@ -185,23 +185,29 @@ function brightnessInstruction(value: number): string {
/** 3D 坐标 → 可读的位置描述(左/右/中、上/下/平、前/后/面上) */
function positionInstruction(position: LightingPosition, label = "light source"): string {
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 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 [
`${label} vector x=${position.x.toFixed(2)}`,
`y=${position.y.toFixed(2)}`,
`z=${position.z.toFixed(2)}`,
`${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(", ");
}
/** 光束角度 → 聚光/泛光 + 正/负倾斜描述 */
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";
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.",
"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.",
"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.",
"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.",

Loading…
Cancel
Save