feat: add aspect ratio and resolution selection for scene fusion
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-01-18 16:44:46 +08:00
parent 153dae3907
commit 8643531e5d
2 changed files with 141 additions and 6 deletions

View File

@ -18,6 +18,8 @@ import sharp from "sharp";
const sceneFusionRequestSchema = z.object({
productImage: z.string().min(1, "产品图不能为空"),
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({
id: z.string(),
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
*/
@ -153,7 +203,8 @@ async function callAlibabaImageAPI(
modelId: string,
productImageUrl: string,
sceneImageUrl: string,
prompt: string
prompt: string,
size?: string
): Promise<string> {
// 构建消息内容:产品图 + 场景图 + 提示词
const content = [
@ -174,6 +225,7 @@ async function callAlibabaImageAPI(
},
parameters: {
n: 1,
...(size && { size }),
prompt_extend: true,
watermark: false,
},
@ -183,6 +235,7 @@ async function callAlibabaImageAPI(
- Index: Scene ${sceneImageUrl.substring(sceneImageUrl.length-15)}
- Product URL: ${productImageUrl}
- Scene URL: ${sceneImageUrl}
- Size: ${size || 'auto'}
- Prompt: ${prompt}
`);
@ -267,7 +320,8 @@ async function processScene(
defaultPrompt: string,
baseURL: string,
apiKey: string,
modelId: string
modelId: string,
size?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
// 构建智能提示词
const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt);
@ -281,7 +335,8 @@ async function processScene(
modelId,
productImageUrl,
sceneImageUrl,
prompt
prompt,
size
);
// 下载并上传到 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) {
@ -400,9 +459,13 @@ export async function POST(request: Request) {
config.defaultPrompt,
provider.baseUrl,
provider.apiKey,
model.modelId
model.modelId,
size
);
// 将分辨率等级用于数据库存储
const dbImageSize = imageSize || "2K";
// 保存到数据库(chatId 和 messageId 设为 null,因为场景裂变不关联聊天)
try {
await db.insert(generatedImage).values({
@ -411,7 +474,7 @@ export async function POST(request: Request) {
messageId: null, // 场景裂变不关联消息
thumbnailUrl: result.thumbnail,
originalUrl: result.original,
imageSize: "2K",
imageSize: dbImageSize,
prompt: `[场景裂变] ${result.prompt}`,
modelId: model.modelId,
});

View File

@ -55,11 +55,30 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
const defaultPrompt = config?.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。";
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 [productName, setProductName] = useState<string>(""); // 产品名称(如"沙发")
const [scenes, setScenes] = useState<SceneItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [aspectRatio, setAspectRatio] = useState<string>("auto"); // 图片比例
const [imageSize, setImageSize] = useState<string>("2K"); // 图片分辨率
// 文件上传 Input 引用
const productInputRef = useRef<HTMLInputElement>(null);
@ -172,6 +191,8 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
body: JSON.stringify({
productImage,
productName: productName || undefined, // 产品名称
aspectRatio, // 图片比例
imageSize, // 图片分辨率
scenes: scenesToProcess.map(s => ({
id: s.id,
image: s.image,
@ -248,6 +269,8 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
body: JSON.stringify({
productImage,
productName: productName || undefined,
aspectRatio, // 图片比例
imageSize, // 图片分辨率
scenes: [{
id: scene.id,
image: scene.image,
@ -400,6 +423,55 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
)}
</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. 场景图列表 */}
<div className="space-y-2">
<div className="flex items-center justify-between">