feat: add aspect ratio and resolution selection for scene fusion
This commit is contained in:
parent
153dae3907
commit
8643531e5d
@ -18,6 +18,8 @@ import sharp from "sharp";
|
|||||||
const sceneFusionRequestSchema = z.object({
|
const sceneFusionRequestSchema = z.object({
|
||||||
productImage: z.string().min(1, "产品图不能为空"),
|
productImage: z.string().min(1, "产品图不能为空"),
|
||||||
productName: z.string().optional(), // 产品名称(如"沙发")
|
productName: z.string().optional(), // 产品名称(如"沙发")
|
||||||
|
aspectRatio: z.string().optional(), // 图片比例(如"1:1", "4:3", "auto")
|
||||||
|
imageSize: z.string().optional(), // 图片分辨率(如"1K", "2K", "4K")
|
||||||
scenes: z.array(z.object({
|
scenes: z.array(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
image: z.string().min(1, "场景图不能为空"),
|
image: z.string().min(1, "场景图不能为空"),
|
||||||
@ -144,6 +146,54 @@ async function downloadAndUploadToS3(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 aspectRatio 和 imageSize 计算实际尺寸
|
||||||
|
* @param aspectRatio - 宽高比,如 "1:1", "4:3", "16:9", "auto"
|
||||||
|
* @param imageSize - 分辨率等级,如 "1K", "2K", "4K"
|
||||||
|
* @returns 尺寸字符串,如 "1024*1024",或 undefined(auto 模式)
|
||||||
|
*/
|
||||||
|
function calculateSize(aspectRatio: string, imageSize: string): string | undefined {
|
||||||
|
// auto 模式:不设置尺寸,让 API 根据输入图片自动决定
|
||||||
|
if (aspectRatio === "auto") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据分辨率等级确定基准像素
|
||||||
|
const baseSizeMap: Record<string, number> = {
|
||||||
|
"1K": 1024,
|
||||||
|
"2K": 1536,
|
||||||
|
"4K": 2048,
|
||||||
|
};
|
||||||
|
const baseSize = baseSizeMap[imageSize] || 1536;
|
||||||
|
|
||||||
|
// 根据比例计算宽高
|
||||||
|
const ratioMap: Record<string, [number, number]> = {
|
||||||
|
"1:1": [1, 1],
|
||||||
|
"4:3": [4, 3],
|
||||||
|
"3:4": [3, 4],
|
||||||
|
"16:9": [16, 9],
|
||||||
|
"9:16": [9, 16],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ratio = ratioMap[aspectRatio];
|
||||||
|
if (!ratio) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [w, h] = ratio;
|
||||||
|
// 保持总像素接近 baseSize * baseSize,同时维持比例
|
||||||
|
const scale = Math.sqrt((baseSize * baseSize) / (w * h));
|
||||||
|
let width = Math.round(w * scale);
|
||||||
|
let height = Math.round(h * scale);
|
||||||
|
|
||||||
|
// 确保宽高在 512-2048 范围内
|
||||||
|
const clamp = (v: number) => Math.max(512, Math.min(2048, v));
|
||||||
|
width = clamp(width);
|
||||||
|
height = clamp(height);
|
||||||
|
|
||||||
|
return `${width}*${height}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用阿里生图 API
|
* 调用阿里生图 API
|
||||||
*/
|
*/
|
||||||
@ -153,7 +203,8 @@ async function callAlibabaImageAPI(
|
|||||||
modelId: string,
|
modelId: string,
|
||||||
productImageUrl: string,
|
productImageUrl: string,
|
||||||
sceneImageUrl: string,
|
sceneImageUrl: string,
|
||||||
prompt: string
|
prompt: string,
|
||||||
|
size?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// 构建消息内容:产品图 + 场景图 + 提示词
|
// 构建消息内容:产品图 + 场景图 + 提示词
|
||||||
const content = [
|
const content = [
|
||||||
@ -174,6 +225,7 @@ async function callAlibabaImageAPI(
|
|||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
n: 1,
|
n: 1,
|
||||||
|
...(size && { size }),
|
||||||
prompt_extend: true,
|
prompt_extend: true,
|
||||||
watermark: false,
|
watermark: false,
|
||||||
},
|
},
|
||||||
@ -183,6 +235,7 @@ async function callAlibabaImageAPI(
|
|||||||
- Index: Scene ${sceneImageUrl.substring(sceneImageUrl.length-15)}
|
- Index: Scene ${sceneImageUrl.substring(sceneImageUrl.length-15)}
|
||||||
- Product URL: ${productImageUrl}
|
- Product URL: ${productImageUrl}
|
||||||
- Scene URL: ${sceneImageUrl}
|
- Scene URL: ${sceneImageUrl}
|
||||||
|
- Size: ${size || 'auto'}
|
||||||
- Prompt: ${prompt}
|
- Prompt: ${prompt}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -267,7 +320,8 @@ async function processScene(
|
|||||||
defaultPrompt: string,
|
defaultPrompt: string,
|
||||||
baseURL: string,
|
baseURL: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
modelId: string
|
modelId: string,
|
||||||
|
size?: string
|
||||||
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||||
// 构建智能提示词
|
// 构建智能提示词
|
||||||
const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt);
|
const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt);
|
||||||
@ -281,7 +335,8 @@ async function processScene(
|
|||||||
modelId,
|
modelId,
|
||||||
productImageUrl,
|
productImageUrl,
|
||||||
sceneImageUrl,
|
sceneImageUrl,
|
||||||
prompt
|
prompt,
|
||||||
|
size
|
||||||
);
|
);
|
||||||
|
|
||||||
// 下载并上传到 S3
|
// 下载并上传到 S3
|
||||||
@ -322,7 +377,11 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { productImage, productName, scenes } = parseResult.data;
|
const { productImage, productName, aspectRatio, imageSize, scenes } = parseResult.data;
|
||||||
|
|
||||||
|
// 计算输出尺寸
|
||||||
|
const size = calculateSize(aspectRatio || "auto", imageSize || "2K");
|
||||||
|
console.log(`[SceneFusion] Output settings: aspectRatio=${aspectRatio || 'auto'}, imageSize=${imageSize || '2K'}, calculated size=${size || 'auto'}`);
|
||||||
|
|
||||||
// 检查场景数量限制
|
// 检查场景数量限制
|
||||||
if (scenes.length > config.maxScenes) {
|
if (scenes.length > config.maxScenes) {
|
||||||
@ -400,9 +459,13 @@ export async function POST(request: Request) {
|
|||||||
config.defaultPrompt,
|
config.defaultPrompt,
|
||||||
provider.baseUrl,
|
provider.baseUrl,
|
||||||
provider.apiKey,
|
provider.apiKey,
|
||||||
model.modelId
|
model.modelId,
|
||||||
|
size
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 将分辨率等级用于数据库存储
|
||||||
|
const dbImageSize = imageSize || "2K";
|
||||||
|
|
||||||
// 保存到数据库(chatId 和 messageId 设为 null,因为场景裂变不关联聊天)
|
// 保存到数据库(chatId 和 messageId 设为 null,因为场景裂变不关联聊天)
|
||||||
try {
|
try {
|
||||||
await db.insert(generatedImage).values({
|
await db.insert(generatedImage).values({
|
||||||
@ -411,7 +474,7 @@ export async function POST(request: Request) {
|
|||||||
messageId: null, // 场景裂变不关联消息
|
messageId: null, // 场景裂变不关联消息
|
||||||
thumbnailUrl: result.thumbnail,
|
thumbnailUrl: result.thumbnail,
|
||||||
originalUrl: result.original,
|
originalUrl: result.original,
|
||||||
imageSize: "2K",
|
imageSize: dbImageSize,
|
||||||
prompt: `[场景裂变] ${result.prompt}`,
|
prompt: `[场景裂变] ${result.prompt}`,
|
||||||
modelId: model.modelId,
|
modelId: model.modelId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -55,11 +55,30 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
|
|||||||
const defaultPrompt = config?.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。";
|
const defaultPrompt = config?.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。";
|
||||||
const maxScenes = config?.maxScenes || 10;
|
const maxScenes = config?.maxScenes || 10;
|
||||||
|
|
||||||
|
// 比例选项
|
||||||
|
const aspectRatioOptions = [
|
||||||
|
{ label: "自动", value: "auto" },
|
||||||
|
{ label: "1:1", value: "1:1" },
|
||||||
|
{ label: "4:3", value: "4:3" },
|
||||||
|
{ label: "3:4", value: "3:4" },
|
||||||
|
{ label: "16:9", value: "16:9" },
|
||||||
|
{ label: "9:16", value: "9:16" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 分辨率选项
|
||||||
|
const imageSizeOptions = [
|
||||||
|
{ label: "标准 (1K)", value: "1K" },
|
||||||
|
{ label: "高清 (2K)", value: "2K" },
|
||||||
|
{ label: "超清 (4K)", value: "4K" },
|
||||||
|
];
|
||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
const [productImage, setProductImage] = useState<string | null>(null);
|
const [productImage, setProductImage] = useState<string | null>(null);
|
||||||
const [productName, setProductName] = useState<string>(""); // 产品名称(如"沙发")
|
const [productName, setProductName] = useState<string>(""); // 产品名称(如"沙发")
|
||||||
const [scenes, setScenes] = useState<SceneItem[]>([]);
|
const [scenes, setScenes] = useState<SceneItem[]>([]);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [aspectRatio, setAspectRatio] = useState<string>("auto"); // 图片比例
|
||||||
|
const [imageSize, setImageSize] = useState<string>("2K"); // 图片分辨率
|
||||||
|
|
||||||
// 文件上传 Input 引用
|
// 文件上传 Input 引用
|
||||||
const productInputRef = useRef<HTMLInputElement>(null);
|
const productInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -172,6 +191,8 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
productImage,
|
productImage,
|
||||||
productName: productName || undefined, // 产品名称
|
productName: productName || undefined, // 产品名称
|
||||||
|
aspectRatio, // 图片比例
|
||||||
|
imageSize, // 图片分辨率
|
||||||
scenes: scenesToProcess.map(s => ({
|
scenes: scenesToProcess.map(s => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
image: s.image,
|
image: s.image,
|
||||||
@ -248,6 +269,8 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
productImage,
|
productImage,
|
||||||
productName: productName || undefined,
|
productName: productName || undefined,
|
||||||
|
aspectRatio, // 图片比例
|
||||||
|
imageSize, // 图片分辨率
|
||||||
scenes: [{
|
scenes: [{
|
||||||
id: scene.id,
|
id: scene.id,
|
||||||
image: scene.image,
|
image: scene.image,
|
||||||
@ -400,6 +423,55 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 1.5 图片尺寸设置 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-medium">输出设置</Label>
|
||||||
|
|
||||||
|
{/* 比例选择 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">图片比例</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{aspectRatioOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setAspectRatio(option.value)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 text-xs rounded-md border transition-colors",
|
||||||
|
aspectRatio === option.value
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-muted border-input"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分辨率选择 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">输出分辨率</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{imageSizeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setImageSize(option.value)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 text-xs rounded-md border transition-colors",
|
||||||
|
imageSize === option.value
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-muted border-input"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 2. 场景图列表 */}
|
{/* 2. 场景图列表 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user