diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 94b479f3..986aaf20 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -383,6 +383,7 @@ export function WorkflowCanvas() { sourceImage: string; settings: LightingSettings | null; } | null>(null); + const [queuedLightingNodeIds, setQueuedLightingNodeIds] = useState([]); // 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); - 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); + 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); + 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); - 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); + setQueuedLightingNodeIds((current) => + current.includes(targetNodeId) ? current : [...current, targetNodeId] + ); + } else { + await regenerateNode(targetNodeId); + } } }, [addNode, edges, getNodeById, isRunning, lightingEditor, nodes, onConnect, regenerateNode, updateNodeData] diff --git a/src/components/__tests__/WorkflowCanvas.test.tsx b/src/components/__tests__/WorkflowCanvas.test.tsx index cd08c43b..b57e81f4 100644 --- a/src/components/__tests__/WorkflowCanvas.test.tsx +++ b/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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + await waitFor(() => { + expect(mockRegenerateNode).toHaveBeenCalledWith("queued-lighting-node"); + }); + }); }); describe("Welcome Modal", () => { diff --git a/src/components/lighting/LightingEffectModal.test.tsx b/src/components/lighting/LightingEffectModal.test.tsx index 1c50ad1d..5d68d55a 100644 --- a/src/components/lighting/LightingEffectModal.test.tsx +++ b/src/components/lighting/LightingEffectModal.test.tsx @@ -33,6 +33,10 @@ function renderLightingModal(overrides: Partial) { 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", () => { diff --git a/src/components/lighting/LightingEffectModal.tsx b/src/components/lighting/LightingEffectModal.tsx index e10adbce..dcf033a2 100644 --- a/src/components/lighting/LightingEffectModal.tsx +++ b/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([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({
+
{MAIN_LIGHT_STOPS.map((stop) => { const stopProjected = projectPositionToSphere(stop.lightPosition, settings.viewMode); diff --git a/src/utils/__tests__/lighting.test.ts b/src/utils/__tests__/lighting.test.ts index f86b0c33..e3c7cd27 100644 --- a/src/utils/__tests__/lighting.test.ts +++ b/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"); }); diff --git a/src/utils/lighting.ts b/src/utils/lighting.ts index 3e1283f9..5e7b2e28 100644 --- a/src/utils/lighting.ts +++ b/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.",