feat: rename scene fusion to detail replace, add auto-retry up to 10 times
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-27 21:47:59 +08:00
parent 0d69e8d9fa
commit 59bc3636e1
5 changed files with 146 additions and 58 deletions

View File

@ -408,7 +408,7 @@ export function AdminSettingsClient() {
timeout: 120,
});
// 场景裂变配置状态
// 详情替换配置状态
const [sceneFusionConfig, setSceneFusionConfig] = useState({
enabled: true,
defaultPrompt: "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。",
@ -499,7 +499,7 @@ export function AdminSettingsClient() {
}
}, [angleEditConfigData]);
// 加载场景裂变配置
// 加载详情替换配置
const { data: sceneFusionConfigData } = useSWR("/api/admin/settings?key=scene_fusion_config", fetcher);
useEffect(() => {
if (sceneFusionConfigData?.setting?.value) {
@ -961,7 +961,7 @@ export function AdminSettingsClient() {
}
};
// --- 场景裂变配置逻辑 ---
// --- 详情替换配置逻辑 ---
const handleSaveSceneFusionConfig = async () => {
try {
if (!sceneFusionConfig.defaultPrompt.trim()) {
@ -984,9 +984,9 @@ export function AdminSettingsClient() {
});
if (!response.ok) throw new Error("保存失败");
toast.success("场景裂变配置保存成功");
toast.success("详情替换配置保存成功");
} catch (error) {
toast.error("保存场景裂变配置失败");
toast.error("保存详情替换配置失败");
}
};
@ -3894,24 +3894,24 @@ export function AdminSettingsClient() {
</CardContent>
</Card>
{/* 场景裂变配置 */}
{/* 详情替换配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
场景裂变配置
详情替换配置
</CardTitle>
<CardDescription>
配置场景裂变功能的默认提示词和参数。场景裂变可将产品图批量融合到多个场景中。
配置详情替换功能的默认提示词和参数。详情替换可将产品图批量融合到多个场景中。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* 功能开关 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">启用场景裂变功能</Label>
<Label className="text-base">启用详情替换功能</Label>
<div className="text-sm text-muted-foreground">
开启后用户可在图片编辑器中使用场景裂变功能
开启后用户可在图片编辑器中使用详情替换功能
</div>
</div>
<Switch
@ -3932,7 +3932,7 @@ export function AdminSettingsClient() {
disabled={!sceneFusionConfig.enabled}
/>
<div className="text-xs text-muted-foreground">
该提示词将作为场景裂变的基础指令,用户可以在此基础上补充关键词
该提示词将作为详情替换的基础指令,用户可以在此基础上添加修改要求
</div>
</div>
@ -3997,7 +3997,7 @@ export function AdminSettingsClient() {
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
选择用于场景裂变的生图模型,支持阿里生图、Gemini、既梦等图片模型
选择用于详情替换的生图模型,支持阿里生图、Gemini、既梦等图片模型
</div>
</div>

View File

@ -109,7 +109,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
'extend': '图片扩展',
'resize': '尺寸调整',
'angle': '角度编辑',
'scene-fusion': '场景裂变',
'scene-fusion': '详情替换',
};
return names[action] || action;
};
@ -428,7 +428,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('scene-fusion')}>
<Layers className="h-4 w-4 mr-2" />
场景裂变
详情替换
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDownload}>
@ -465,7 +465,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
/>
)}
{/* 场景裂变对话框 */}
{/* 详情替换对话框 */}
<SceneFusionDialog
open={sceneFusionDialogOpen}
onOpenChange={setSceneFusionDialogOpen}

View File

@ -1297,18 +1297,18 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
size="sm"
onClick={() => {
setShowSceneFusion(!showSceneFusion);
// 进入场景裂变时,退出其他模式
// 进入详情替换时,退出其他模式
if (!showSceneFusion) {
setIsResizeMode(false);
setShowAngleEditDialog(false);
}
}}
disabled={showAngleEditDialog || isRemovingBg || isEnhancing || isExtending || isResizeMode}
title="场景裂变"
title="详情替换"
className={showSceneFusion ? "bg-primary text-white" : "text-zinc-300 hover:text-white hover:bg-zinc-800"}
>
<Layers className="mr-1 h-4 w-4" />
场景裂变
详情替换
</Button>
{!showSceneFusion && (
<>

View File

@ -36,7 +36,8 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
export interface SceneItem {
id: string;
image: string; // 场景图 URL 或 Base64
customPrompt: string; // 该场景的补充关键词
customPrompt: string; // 该场景的修改要求
retryCount?: number; // 重试次数
status: 'pending' | 'processing' | 'completed' | 'failed';
result?: string; // 生成结果缩略图 URL
originalUrl?: string; // 生成结果原图 URL(用于下载)
@ -49,7 +50,10 @@ interface SceneFusionDialogProps {
}
export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps) {
// 获取场景裂变配置(使用公开 API 端点)
// 最大自动重试次数
const MAX_AUTO_RETRY = 10;
// 获取详情替换配置(使用公开 API 端点)
const { data: configData } = useSWR(
open ? "/api/scene-fusion/config" : null,
fetcher
@ -140,7 +144,7 @@ export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps
// 开始裂变
const startFusion = async () => {
if (config?.enabled === false) {
toast.error("场景裂变功能已被管理员禁用");
toast.error("详情替换功能已被管理员禁用");
return;
}
if (!productImage) {
@ -226,16 +230,19 @@ export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps
}
};
// 重新生成单个场景
const regenerateScene = async (id: string) => {
// 重新生成单个场景(带自动重试)
const regenerateScene = async (id: string, retryCount: number = 0) => {
if (!productImage) return;
const scene = scenes.find(s => s.id === id);
if (!scene) return;
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, status: 'processing', error: undefined } : s
));
// 只在首次调用时显示处理状态
if (retryCount === 0) {
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, status: 'processing', error: undefined, retryCount: 0 } : s
));
}
try {
const response = await fetch('/api/scene-fusion', {
@ -300,9 +307,10 @@ export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps
status: 'completed',
result: result.result,
originalUrl: result.originalUrl,
retryCount: 0,
} : s
));
toast.success('重新生成成功');
toast.success('生成成功');
success = true;
} else {
throw new Error(result.error || '生成失败');
@ -325,11 +333,25 @@ export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps
throw new Error('未收到有效的生成结果');
}
} catch (error) {
console.error('[SceneFusion] Regenerate error:', error);
console.error(`[SceneFusion] Regenerate error (retry ${retryCount}/${MAX_AUTO_RETRY}):`, error);
// 检查是否需要自动重试
if (retryCount < MAX_AUTO_RETRY) {
console.log(`[SceneFusion] Auto-retrying scene ${id} (${retryCount + 1}/${MAX_AUTO_RETRY})...`);
// 更新重试计数(用户无感)
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, retryCount: retryCount + 1 } : s
));
// 延迟2秒后自动重试
await new Promise(resolve => setTimeout(resolve, 2000));
return regenerateScene(id, retryCount + 1);
}
// 达到最大重试次数,显示失败
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, status: 'failed', error: error instanceof Error ? error.message : '处理失败' } : s
s.id === id ? { ...s, status: 'failed', error: `重试${MAX_AUTO_RETRY}次后失败: ${error instanceof Error ? error.message : '处理失败'}` } : s
));
toast.error(error instanceof Error ? error.message : '重新生成失败');
toast.error(`重试${MAX_AUTO_RETRY}次后仍然失败`);
}
};
@ -388,7 +410,7 @@ export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="flex items-center gap-2">
<Layers className="w-5 h-5 text-primary" />
场景裂变
详情替换
</DialogTitle>
<DialogDescription>
上传1张产品图和多张场景图,AI 自动将产品融合到所有场景中。
@ -505,7 +527,7 @@ export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps
)}
</div>
<Input
placeholder="补充关键词:如'放在木桌上'"
placeholder="修改要求:如'放在木桌上'"
value={scene.customPrompt}
onChange={(e) => updateScenePrompt(scene.id, e.target.value)}
className="h-8 text-xs"

View File

@ -27,7 +27,8 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
export interface SceneItem {
id: string;
image: string; // 场景图 URL 或 Base64
customPrompt: string; // 该场景的补充关键词
customPrompt: string; // 该场景的修改要求
retryCount?: number; // 重试次数
targetName: string; // 背景中需要被替换的物体名称
status: 'pending' | 'processing' | 'completed' | 'failed';
result?: string; // 生成结果缩略图 URL
@ -40,7 +41,10 @@ interface SceneFusionViewProps {
}
export function SceneFusionView({ onBack }: SceneFusionViewProps) {
// 获取场景裂变配置(使用公开 API 端点)
// 最大自动重试次数
const MAX_AUTO_RETRY = 10;
// 获取详情替换配置(使用公开 API 端点)
const { data: configData } = useSWR(
"/api/scene-fusion/config",
fetcher
@ -159,7 +163,7 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
// 开始裂变
const startFusion = async () => {
if (config?.enabled === false) {
toast.error("场景裂变功能已被管理员禁用");
toast.error("详情替换功能已被管理员禁用");
return;
}
if (!productImage) {
@ -270,14 +274,29 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
status: 'completed',
result: result.result,
originalUrl: result.originalUrl,
retryCount: 0, // 重置重试计数
};
} else {
failedCount++;
return {
...s,
status: 'failed',
error: result.error || '生成失败'
};
// 检查是否需要自动重试
const currentRetryCount = s.retryCount || 0;
if (currentRetryCount < MAX_AUTO_RETRY) {
console.log(`[SceneFusion] Scene ${result.id} failed, will auto-retry (${currentRetryCount + 1}/${MAX_AUTO_RETRY})`);
// 标记为需要重试,稍后会自动重试
return {
...s,
status: 'pending', // 改为 pending 以便重新处理
error: undefined,
retryCount: currentRetryCount + 1,
};
} else {
failedCount++;
console.log(`[SceneFusion] Scene ${result.id} failed after ${MAX_AUTO_RETRY} retries`);
return {
...s,
status: 'failed',
error: `重试${MAX_AUTO_RETRY}次后仍然失败: ${result.error || '生成失败'}`
};
}
}
}
return s;
@ -286,9 +305,9 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
const { summary } = message;
console.log('[SceneFusion Client] All done:', summary);
if (summary.failed > 0) {
toast.warning(`批量处理完成:${summary.success} 成功,${summary.failed} 失败`);
} else {
// 检查是否有需要自动重试的场景
// 注意:这里不直接调用,而是在 finally 中检查
if (summary.failed === 0) {
toast.success(`批量处理完成:${summary.success} 张图片已生成`);
}
} else if (message.type === 'error') {
@ -308,25 +327,57 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
console.error('[SceneFusion] Error:', error);
toast.error(error instanceof Error ? error.message : '处理失败');
// 将 processing 状态改回 failed
setScenes(prev => prev.map(s =>
s.status === 'processing' ? { ...s, status: 'failed', error: '处理失败' } : s
));
// 将 processing 状态改回 pending(以便自动重试)或 failed
setScenes(prev => prev.map(s => {
if (s.status === 'processing') {
const currentRetryCount = s.retryCount || 0;
if (currentRetryCount < MAX_AUTO_RETRY) {
return { ...s, status: 'pending', retryCount: currentRetryCount + 1 };
}
return { ...s, status: 'failed', error: '处理失败' };
}
return s;
}));
} finally {
setIsProcessing(false);
// 检查是否有需要自动重试的场景(状态为 pending 且 retryCount > 0)
// 使用 setTimeout 延迟执行,避免立即重试
setTimeout(() => {
setScenes(currentScenes => {
const pendingRetries = currentScenes.filter(s => s.status === 'pending' && (s.retryCount || 0) > 0);
if (pendingRetries.length > 0) {
console.log(`[SceneFusion] Auto-retrying ${pendingRetries.length} failed scenes...`);
// 触发重新处理 - 由于 startFusion 会处理 pending 状态的场景
// 我们需要在下一个事件循环中调用
setTimeout(() => startFusion(), 100);
} else {
// 所有场景都处理完成,检查最终结果
const failed = currentScenes.filter(s => s.status === 'failed').length;
const completed = currentScenes.filter(s => s.status === 'completed').length;
if (failed > 0 && completed > 0) {
toast.warning(`批量处理完成:${completed} 成功,${failed} 失败`);
}
}
return currentScenes;
});
}, 500);
}
};
// 重新生成单个场景
const regenerateScene = async (id: string) => {
// 重新生成单个场景(带自动重试)
const regenerateScene = async (id: string, retryCount: number = 0) => {
if (!productImage) return;
const scene = scenes.find(s => s.id === id);
if (!scene) return;
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, status: 'processing', error: undefined } : s
));
// 只在首次调用时显示处理状态
if (retryCount === 0) {
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, status: 'processing', error: undefined, retryCount: 0 } : s
));
}
try {
const response = await fetch('/api/scene-fusion', {
@ -395,9 +446,10 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
status: 'completed',
result: result.result,
originalUrl: result.originalUrl,
retryCount: 0,
} : s
));
toast.success('重新生成成功');
toast.success('生成成功');
success = true;
} else {
throw new Error(result.error || '生成失败');
@ -420,9 +472,23 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
throw new Error('未收到有效的生成结果');
}
} catch (error) {
console.error('[SceneFusion] Regenerate error:', error);
console.error(`[SceneFusion] Regenerate error (retry ${retryCount}/${MAX_AUTO_RETRY}):`, error);
// 检查是否需要自动重试
if (retryCount < MAX_AUTO_RETRY) {
console.log(`[SceneFusion] Auto-retrying scene ${id} (${retryCount + 1}/${MAX_AUTO_RETRY})...`);
// 更新重试计数(用户无感)
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, retryCount: retryCount + 1 } : s
));
// 延迟2秒后自动重试
await new Promise(resolve => setTimeout(resolve, 2000));
return regenerateScene(id, retryCount + 1);
}
// 达到最大重试次数,显示失败
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, status: 'failed', error: error instanceof Error ? error.message : '处理失败' } : s
s.id === id ? { ...s, status: 'failed', error: `重试${MAX_AUTO_RETRY}次后失败: ${error instanceof Error ? error.message : '处理失败'}` } : s
));
toast.error(error instanceof Error ? error.message : '重新生成失败');
}
@ -488,7 +554,7 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
</Button>
<div className="flex items-center gap-2 font-medium">
<Layers className="w-5 h-5 text-primary" />
场景裂变
详情替换
</div>
</div>
<div className="text-sm text-muted-foreground hidden md:block">
@ -678,7 +744,7 @@ export function SceneFusionView({ onBack }: SceneFusionViewProps) {
disabled={isProcessing}
/>
<Input
placeholder="补充关键词(可选)"
placeholder="修改要求(可选)"
value={scene.customPrompt}
onChange={(e) => updateScenePrompt(scene.id, e.target.value)}
className="h-7 text-xs"