ai-chatbot/lib/canvas/scale-adjust-model-relight.ts

379 lines
12 KiB
TypeScript

import "server-only";
import sharp from "sharp";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import type {
CanvasGenerateImageRequest,
CanvasGenerateImageResult,
CanvasLocalEditCompositeRegion,
CanvasScaleAdjustModelRelightConfig,
} from "@/lib/canvas/generate-image";
import { uploadFile } from "@/lib/storage/service";
type GenerateCanvasImage = (
request: CanvasGenerateImageRequest
) => Promise<CanvasGenerateImageResult>;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function bufferToArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
function rectToPixelBox(params: {
height: number;
region: CanvasLocalEditCompositeRegion;
width: number;
}) {
const { height, region, width } = params;
const left = clamp(Math.floor(region.x * width), 0, Math.max(0, width - 1));
const top = clamp(Math.floor(region.y * height), 0, Math.max(0, height - 1));
const right = clamp(
Math.ceil((region.x + region.width) * width),
left + 1,
width
);
const bottom = clamp(
Math.ceil((region.y + region.height) * height),
top + 1,
height
);
return { bottom, left, right, top };
}
async function buildModelRelightMask(params: {
config: CanvasScaleAdjustModelRelightConfig;
height: number;
width: number;
}) {
const { config, height, width } = params;
const productAlpha = await sharp(
await fetchImageBufferFromUrl(config.overlayImageUrl),
{ failOn: "none" }
)
.rotate()
.resize(width, height, { fit: "fill", kernel: sharp.kernel.lanczos3 })
.ensureAlpha()
.extractChannel(3)
.raw()
.toBuffer();
const editableMask = Buffer.alloc(width * height);
for (const item of config.regions) {
const target = item.target;
const expandedFloorRegion =
config.surfaceMode === "surround"
? {
x: target.x - target.width * 0.22,
y: target.y - target.height * 0.08,
width: target.width * 1.52,
height: target.height * 1.22,
}
: {
x: target.x - target.width * 0.16,
y: target.y + target.height * 0.68,
width: target.width * 1.32,
height: target.height * 0.5,
};
const box = rectToPixelBox({
height,
region: expandedFloorRegion,
width,
});
for (let y = box.top; y < box.bottom; y += 1) {
editableMask.fill(255, y * width + box.left, y * width + box.right);
}
}
for (let index = 0; index < editableMask.length; index += 1) {
if (productAlpha[index] >= 8) editableMask[index] = 0;
}
const maskBuffer = await sharp(editableMask, {
raw: { channels: 1, height, width },
})
.png({ compressionLevel: 6 })
.toBuffer();
return { editableMask, maskBuffer, productAlpha };
}
function findMedianLumaDelta(params: {
editableMask: Buffer;
exactRaw: Buffer;
generatedRaw: Buffer;
}) {
const { editableMask, exactRaw, generatedRaw } = params;
const histogram = new Uint32Array(511);
let sampleCount = 0;
for (
let maskIndex = 0, rgbIndex = 0;
maskIndex < editableMask.length;
maskIndex += 1, rgbIndex += 3
) {
if (editableMask[maskIndex] < 240) continue;
const exactLuma =
(77 * exactRaw[rgbIndex] +
150 * exactRaw[rgbIndex + 1] +
29 * exactRaw[rgbIndex + 2]) >>
8;
const generatedLuma =
(77 * generatedRaw[rgbIndex] +
150 * generatedRaw[rgbIndex + 1] +
29 * generatedRaw[rgbIndex + 2]) >>
8;
const bucket = clamp(exactLuma - generatedLuma + 255, 0, 510);
histogram[bucket] += 1;
sampleCount += 1;
}
if (sampleCount === 0) return { median: 0, sampleCount };
let seen = 0;
for (let index = 0; index < histogram.length; index += 1) {
seen += histogram[index];
if (seen >= sampleCount / 2) {
return { median: index - 255, sampleCount };
}
}
return { median: 0, sampleCount };
}
async function mergeModelShadowField(params: {
editableMask: Buffer;
exactBuffer: Buffer;
generatedBuffer: Buffer;
height: number;
productAlpha: Buffer;
width: number;
}) {
const {
editableMask,
exactBuffer,
generatedBuffer,
height,
productAlpha,
width,
} = params;
const [exactRaw, generatedRaw] = await Promise.all([
sharp(exactBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer(),
sharp(generatedBuffer, { failOn: "none" })
.rotate()
.resize(width, height, { fit: "fill", kernel: sharp.kernel.lanczos3 })
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer(),
]);
const { median, sampleCount } = findMedianLumaDelta({
editableMask,
exactRaw,
generatedRaw,
});
if (sampleCount < 128) return null;
const shadowAlpha = Buffer.alloc(width * height);
let rawShadowStrength = 0;
for (
let maskIndex = 0, rgbIndex = 0;
maskIndex < editableMask.length;
maskIndex += 1, rgbIndex += 3
) {
if (editableMask[maskIndex] < 240) continue;
const exactLuma =
(77 * exactRaw[rgbIndex] +
150 * exactRaw[rgbIndex + 1] +
29 * exactRaw[rgbIndex + 2]) >>
8;
const generatedLuma =
(77 * generatedRaw[rgbIndex] +
150 * generatedRaw[rgbIndex + 1] +
29 * generatedRaw[rgbIndex + 2]) >>
8;
const modelDarkness = Math.max(
0,
exactLuma - generatedLuma - median - 12
);
const alpha = Math.min(58, Math.round(modelDarkness * 0.78));
shadowAlpha[maskIndex] = alpha;
rawShadowStrength += alpha;
}
if (rawShadowStrength < 96) return null;
const blurSigma = clamp(Math.min(width, height) * 0.0063, 6, 24);
const softenedAlpha = await sharp(shadowAlpha, {
raw: { channels: 1, height, width },
})
.blur(blurSigma)
.extractChannel(0)
.raw()
.toBuffer();
const shadowLayer = Buffer.alloc(width * height * 4);
let appliedShadowStrength = 0;
for (
let pixelIndex = 0, rgbaIndex = 0;
pixelIndex < softenedAlpha.length;
pixelIndex += 1, rgbaIndex += 4
) {
const alpha = productAlpha[pixelIndex] >= 8 ? 0 : softenedAlpha[pixelIndex];
shadowLayer[rgbaIndex] = 32;
shadowLayer[rgbaIndex + 1] = 32;
shadowLayer[rgbaIndex + 2] = 32;
shadowLayer[rgbaIndex + 3] = alpha;
appliedShadowStrength += alpha;
}
if (appliedShadowStrength < 96) return null;
const mergedBuffer = await sharp(exactBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.ensureAlpha()
.composite([
{
input: shadowLayer,
raw: { channels: 4, height, width },
blend: "over",
},
])
.png({ compressionLevel: 6 })
.toBuffer();
return { appliedShadowStrength, median, mergedBuffer, sampleCount };
}
export async function applyScaleAdjustModelRelight(params: {
generateImage: GenerateCanvasImage;
initialResult: CanvasGenerateImageResult;
ownerKey?: string;
request: CanvasGenerateImageRequest;
}) {
const { generateImage, initialResult, ownerKey, request } = params;
const config = request.scaleAdjustModelRelight;
if (!config || initialResult.images.length === 0) return initialResult;
const relitImages = [] as CanvasGenerateImageResult["images"];
for (const [index, image] of initialResult.images.entries()) {
const exactUrl = image.originalUrl || image.thumbnailUrl;
try {
const exactBuffer = await fetchImageBufferFromUrl(exactUrl);
const metadata = await sharp(exactBuffer, { failOn: "none" }).metadata();
const width = Math.round(Number(metadata.width || 0));
const height = Math.round(Number(metadata.height || 0));
if (width <= 0 || height <= 0) throw new Error("精确合成结果尺寸无效");
const { editableMask, maskBuffer, productAlpha } =
await buildModelRelightMask({ config, height, width });
const suffix = `${Date.now()}-${index + 1}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const maskUpload = await uploadFile(
`canvas-scale-adjust-model-relight-mask-${suffix}.png`,
bufferToArrayBuffer(maskBuffer)
);
const modelResult = await generateImage({
allowFailover: false,
aspectRatio: "auto",
debugLabel: "canvas-scale-adjust-model-relight",
debugMeta: {
operation: "scale-adjust-model-relight",
scaleAdjustModelRelightApplied: true,
scaleAdjustProviderMaskImageUrl: maskUpload.url,
sourceHeight: config.sourceHeight,
sourceWidth: config.sourceWidth,
},
displayPrompt: request.displayPrompt,
imageCount: 1,
imageSize: "auto",
model: config.model,
negativePrompt:
"复制产品、增加产品、移动产品、改变产品造型、改变构图、修改人物、文字、标注、边框",
ownerKey,
prompt:
config.surfaceMode === "surround"
? "仅编辑白色遮罩中的产品周围表面。根据画面现有主光方向、环境光颜色、产品与墙面或地面的接触关系,为当前位置的产品生成真实自然的接触阴影与投射阴影。阴影方向、软硬、颜色和强度必须与室内现有光照一致。黑色区域保持像素级不变;不要重绘、复制、移动或变形产品,不要修改人物、家具、墙面纹理和构图。"
: "仅编辑白色遮罩中的地面区域。根据画面现有主光方向、环境光颜色、产品落地点和地面材质,为当前位置的产品生成真实自然的接触阴影与投射阴影。阴影必须贴合产品落地边缘,方向、软硬、颜色和强度与室内现有光照一致。黑色区域保持像素级不变;不要重绘、复制、移动或变形产品,不要修改人物、家具、墙面和构图。",
promptExtend: false,
providerImageParams: {
maskImageUrl: maskUpload.url,
quality: config.quality || "medium",
},
referenceImages: [exactUrl],
});
const generatedUrl =
modelResult.images[0]?.originalUrl ||
modelResult.images[0]?.thumbnailUrl ||
"";
if (!generatedUrl) throw new Error("光影模型未返回图片");
const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl);
const merged = await mergeModelShadowField({
editableMask,
exactBuffer,
generatedBuffer,
height,
productAlpha,
width,
});
if (!merged) throw new Error("光影模型未生成有效的地面明暗变化");
const finalJpegBuffer = await sharp(merged.mergedBuffer, {
failOn: "none",
})
.flatten({ background: "#ffffff" })
.jpeg({ quality: 94, mozjpeg: true })
.toBuffer();
const thumbnailBuffer = await sharp(merged.mergedBuffer, {
failOn: "none",
})
.flatten({ background: "#ffffff" })
.resize(512, 512, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 88, mozjpeg: true })
.toBuffer();
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-scale-adjust-model-relit-${suffix}.jpg`,
bufferToArrayBuffer(finalJpegBuffer)
),
uploadFile(
`canvas-scale-adjust-model-relit-thumb-${suffix}.jpg`,
bufferToArrayBuffer(thumbnailBuffer)
),
]);
console.info("[Canvas Scale Adjust] Model relight applied:", {
appliedShadowStrength: merged.appliedShadowStrength,
exactUrl,
generatedUrl,
height,
medianLumaDelta: merged.median,
sampleCount: merged.sampleCount,
width,
});
relitImages.push({
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
});
} catch (error) {
console.error(
"[Canvas Scale Adjust] Model relight failed; keeping verified local shadow fallback:",
{
error: error instanceof Error ? error.message : String(error),
exactUrl,
index,
}
);
relitImages.push(image);
}
}
return {
...initialResult,
images: relitImages,
};
}