docs: 添加共享模块设计规则
- 清理 CLAUDE.md 中重复的规则 8-10 - 添加规则 11:共享模块设计(*-core.ts 命名规范) - 在 CODING_RULES.md 添加详细示例(TrimMode 配置模式)
This commit is contained in:
1 parent
718c4fbb53
commit
53d0900a3c
13 files changed
+560
-222
No files matched your search
@@ -140,6 +140,7 @@ Service Worker (后台执行)
|
||||
9. **结构化数据**:复杂消息展示应优先使用 `aiContext` 等结构化数据而非字符串解析
|
||||
10. **图标验证**:使用 `tdesign-icons-react` 前需验证导出名称是否存在(如 `ServiceIcon`)
|
||||
11. **外部 API 调用频率**:低频刷新的外部接口(如健康检查、状态查询)必须使用单例控制调用频率
|
||||
12. **共享模块设计**:相似功能提取到 `*-core.ts` 核心模块,通过配置类型(如 `TrimMode`)区分行为
|
||||
|
||||
#### 外部 API 调用频率控制
|
||||
|
||||
|
||||
@@ -809,12 +809,19 @@ export class WorkflowExecutor {
|
||||
// Handle response based on tool type
|
||||
const resultData = response.result as any;
|
||||
|
||||
// For generate_image/generate_video, the result contains taskId
|
||||
// For image/video generation tools, the result contains taskId
|
||||
// The step should be marked as 'running' until the task completes
|
||||
if ((step.mcp === 'generate_image' || step.mcp === 'generate_video') && response.taskId) {
|
||||
const imageVideoTools = ['generate_image', 'generate_video', 'generate_grid_image', 'generate_inspiration_board'];
|
||||
if (imageVideoTools.includes(step.mcp) && response.taskId) {
|
||||
const typeMap: Record<string, string> = {
|
||||
'generate_image': 'image',
|
||||
'generate_grid_image': 'image',
|
||||
'generate_inspiration_board': 'image',
|
||||
'generate_video': 'video',
|
||||
};
|
||||
step.result = {
|
||||
success: true,
|
||||
type: step.mcp === 'generate_image' ? 'image' : 'video',
|
||||
type: typeMap[step.mcp] || 'image',
|
||||
data: resultData,
|
||||
taskId: response.taskId,
|
||||
taskIds: response.taskIds,
|
||||
|
||||
@@ -264,6 +264,54 @@ const completedTasks = tasks.filter(
|
||||
**相关文件**:
|
||||
- `apps/web/src/sw/task-queue/types.ts` - SW 枚举定义
|
||||
|
||||
#### 共享模块与统一配置模式
|
||||
|
||||
**场景**: 多个功能模块有相似逻辑但细节不同时(如宫格图和灵感图的拆分)
|
||||
|
||||
❌ **错误示例**:
|
||||
```typescript
|
||||
// image-splitter.ts - 重复的去白边逻辑
|
||||
function splitGrid(imageUrl: string) {
|
||||
const borders = trimBorders(imageData, 0.5, 0.15);
|
||||
// ...
|
||||
}
|
||||
|
||||
// photo-wall-splitter.ts - 几乎相同的逻辑
|
||||
function splitPhotoWall(imageUrl: string) {
|
||||
const borders = trimBorders(imageData, 0.5, 0.15);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
✅ **正确示例**:
|
||||
```typescript
|
||||
// image-split-core.ts - 核心模块,统一配置
|
||||
export type TrimMode = 'strict' | 'normal' | 'none';
|
||||
|
||||
export function getTrimParams(trimMode: TrimMode) {
|
||||
switch (trimMode) {
|
||||
case 'strict': return { borderRatio: 1.0, maxTrimRatio: 0.05 };
|
||||
case 'normal': return { borderRatio: 0.95, maxTrimRatio: 0.05 };
|
||||
case 'none': return null;
|
||||
}
|
||||
}
|
||||
|
||||
// image-splitter.ts - 使用配置区分行为
|
||||
const trimMode: TrimMode = isStandardGrid ? 'strict' : 'normal';
|
||||
const params = getTrimParams(trimMode);
|
||||
if (params) {
|
||||
borders = trimBorders(imageData, params.borderRatio, params.maxTrimRatio);
|
||||
}
|
||||
```
|
||||
|
||||
**原因**:
|
||||
1. 避免代码重复,便于统一维护
|
||||
2. 通过配置类型区分行为,而非复制代码
|
||||
3. 核心模块命名规范:`*-core.ts`(如 `image-split-core.ts`)
|
||||
|
||||
**相关文件**:
|
||||
- `packages/drawnix/src/utils/image-split-core.ts` - 图片拆分核心模块
|
||||
|
||||
### React 组件规范
|
||||
- 使用函数组件和 Hooks
|
||||
- 使用 `React.memo` 优化重渲染
|
||||
|
||||
@@ -605,16 +605,35 @@ export const AIInputBar: React.FC<AIInputBarProps> = React.memo(({ className, is
|
||||
|
||||
// 关闭 ChatDrawer(如果是由 AIInputBar 触发的对话)
|
||||
// 注意:这里使用 setTimeout 确保消息更新后再关闭
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
chatDrawerControl.closeChatDrawer();
|
||||
|
||||
// 删除 WorkZone(因为图片已经插入画布)
|
||||
const workZoneId = currentWorkZoneIdRef.current;
|
||||
const board = SelectionWatcherBoardRef.current;
|
||||
if (workZoneId && board) {
|
||||
WorkZoneTransforms.removeWorkZone(board, workZoneId);
|
||||
currentWorkZoneIdRef.current = null;
|
||||
// console.log('[AIInputBar] Removed WorkZone after completion:', workZoneId);
|
||||
// 只有当所有步骤都完成后才删除
|
||||
const workflow = workflowControl.getWorkflow();
|
||||
const allStepsFinished = workflow?.steps.every(
|
||||
s => s.status === 'completed' || s.status === 'failed' || s.status === 'skipped'
|
||||
);
|
||||
|
||||
if (allStepsFinished) {
|
||||
const { workflowCompletionService } = await import('../../services/workflow-completion-service');
|
||||
const allPostProcessingFinished = workflow?.steps.every(step => {
|
||||
const stepResult = step.result as { taskId?: string } | undefined;
|
||||
if (stepResult?.taskId) {
|
||||
return workflowCompletionService.isPostProcessingCompleted(stepResult.taskId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (allPostProcessingFinished) {
|
||||
WorkZoneTransforms.removeWorkZone(board, workZoneId);
|
||||
currentWorkZoneIdRef.current = null;
|
||||
// console.log('[AIInputBar] Removed WorkZone after completion:', workZoneId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动画布到插入元素的位置
|
||||
@@ -1070,7 +1089,9 @@ export const AIInputBar: React.FC<AIInputBarProps> = React.memo(({ className, is
|
||||
if (usedSW) {
|
||||
if (prompt.trim()) {
|
||||
const hasSelection = allContent.length > 0;
|
||||
addPromptHistory(prompt.trim(), hasSelection);
|
||||
// 将 generationType 'text' 映射为 'agent' 用于历史记录
|
||||
const modelType = generationType === 'text' ? 'agent' : generationType;
|
||||
addPromptHistory(prompt.trim(), hasSelection, modelType);
|
||||
}
|
||||
setPrompt('');
|
||||
setSelectedContent([]);
|
||||
@@ -1290,7 +1311,9 @@ export const AIInputBar: React.FC<AIInputBarProps> = React.memo(({ className, is
|
||||
// 保存提示词到历史记录(只保存有实际内容的提示词)
|
||||
if (prompt.trim()) {
|
||||
const hasSelection = allContent.length > 0;
|
||||
addPromptHistory(prompt.trim(), hasSelection);
|
||||
// 将 generationType 'text' 映射为 'agent' 用于历史记录
|
||||
const modelTypeForHistory = generationType === 'text' ? 'agent' : generationType;
|
||||
addPromptHistory(prompt.trim(), hasSelection, modelTypeForHistory);
|
||||
}
|
||||
|
||||
// 检查工作流是否已完成(所有步骤都是 completed 或 failed/skipped)
|
||||
@@ -1306,12 +1329,27 @@ export const AIInputBar: React.FC<AIInputBarProps> = React.memo(({ className, is
|
||||
const workZoneId = currentWorkZoneIdRef.current;
|
||||
const board = SelectionWatcherBoardRef.current;
|
||||
if (workZoneId && board) {
|
||||
// 延迟删除,让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
WorkZoneTransforms.removeWorkZone(board, workZoneId);
|
||||
currentWorkZoneIdRef.current = null;
|
||||
// console.log('[AIInputBar] Removed WorkZone after all steps completed (no tasks):', workZoneId);
|
||||
}, 1500);
|
||||
// 检查是否所有后处理都已完成
|
||||
const allPostProcessingFinished = finalWorkflow?.steps.every(step => {
|
||||
const stepResult = step.result as { taskId?: string } | undefined;
|
||||
if (stepResult?.taskId) {
|
||||
const isCompleted = workflowCompletionService.isPostProcessingCompleted(stepResult.taskId);
|
||||
// console.log(`[AIInputBar] Task ${stepResult.taskId} post-processing finished:`, isCompleted);
|
||||
return isCompleted;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// console.log(`[AIInputBar] WorkZone ${workZoneId} allStepsFinished: ${allStepsFinished}, hasCreatedTasks: ${hasCreatedTasks}, allPostProcessingFinished: ${allPostProcessingFinished}`);
|
||||
|
||||
if (allPostProcessingFinished) {
|
||||
// 延迟删除,让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
WorkZoneTransforms.removeWorkZone(board, workZoneId);
|
||||
currentWorkZoneIdRef.current = null;
|
||||
// console.log('[AIInputBar] Removed WorkZone after all steps completed (no tasks):', workZoneId);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1553,11 +1591,26 @@ export const AIInputBar: React.FC<AIInputBarProps> = React.memo(({ className, is
|
||||
const workZoneId = currentWorkZoneIdRef.current;
|
||||
const board = SelectionWatcherBoardRef.current;
|
||||
if (workZoneId && board) {
|
||||
// 延迟删除,让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
WorkZoneTransforms.removeWorkZone(board, workZoneId);
|
||||
currentWorkZoneIdRef.current = null;
|
||||
}, 1500);
|
||||
// 检查是否所有后处理都已完成
|
||||
const allPostProcessingFinished = finalWorkflow?.steps.every(step => {
|
||||
const stepResult = step.result as { taskId?: string } | undefined;
|
||||
if (stepResult?.taskId) {
|
||||
const isCompleted = workflowCompletionService.isPostProcessingCompleted(stepResult.taskId);
|
||||
// console.log(`[AIInputBar] Retry task ${stepResult.taskId} post-processing finished:`, isCompleted);
|
||||
return isCompleted;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// console.log(`[AIInputBar] Retry WorkZone ${workZoneId} allStepsFinished: ${allStepsFinished}, hasCreatedTasks: ${hasCreatedTasks}, allPostProcessingFinished: ${allPostProcessingFinished}`);
|
||||
|
||||
if (allPostProcessingFinished) {
|
||||
// 延迟删除,让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
WorkZoneTransforms.removeWorkZone(board, workZoneId);
|
||||
currentWorkZoneIdRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,31 +56,41 @@ export const PromptHistoryPopover: React.FC<PromptHistoryPopoverProps> = ({
|
||||
}));
|
||||
}, [language]);
|
||||
|
||||
// 合并历史记录和预设提示词,去重
|
||||
// 历史提示词最大展示数量(数据保留,只限制展示)
|
||||
const MAX_HISTORY_DISPLAY = 100;
|
||||
|
||||
// 合并历史记录和预设提示词
|
||||
// 规则:预设提示词永久展示,历史提示词最多展示 100 条
|
||||
const promptItems: PromptItem[] = useMemo(() => {
|
||||
// 历史记录
|
||||
const historyItems: PromptItem[] = history.map(item => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
pinned: item.pinned,
|
||||
}));
|
||||
// 获取预设提示词内容集合(用于过滤历史记录中的重复项)
|
||||
const presetContents = new Set(presetPrompts.map(p => p.content.trim().toLowerCase()));
|
||||
|
||||
// 获取历史记录中的内容集合(用于去重)
|
||||
const historyContents = new Set(history.map(h => h.content.trim().toLowerCase()));
|
||||
|
||||
// 过滤掉与历史记录重复的预设
|
||||
const filteredPresets: PromptItem[] = presetPrompts
|
||||
.filter(p => !historyContents.has(p.content.trim().toLowerCase()))
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
content: p.content,
|
||||
pinned: false,
|
||||
isPreset: true,
|
||||
modelType: p.modelType,
|
||||
scene: p.scene,
|
||||
// 历史记录:过滤掉与预设重复的内容,然后限制展示数量
|
||||
// 这样可以避免同一条内容同时出现在历史和预设中
|
||||
const filteredHistory = history.filter(
|
||||
item => !presetContents.has(item.content.trim().toLowerCase())
|
||||
);
|
||||
const historyItems: PromptItem[] = filteredHistory
|
||||
.slice(0, MAX_HISTORY_DISPLAY)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
pinned: item.pinned,
|
||||
modelType: item.modelType,
|
||||
}));
|
||||
|
||||
return [...historyItems, ...filteredPresets];
|
||||
// 预设提示词:永久展示,不受历史记录影响
|
||||
const presetItems: PromptItem[] = presetPrompts.map(p => ({
|
||||
id: p.id,
|
||||
content: p.content,
|
||||
pinned: false,
|
||||
isPreset: true,
|
||||
modelType: p.modelType,
|
||||
scene: p.scene,
|
||||
}));
|
||||
|
||||
// 历史记录在前(置顶的优先),预设在后
|
||||
return [...historyItems, ...presetItems];
|
||||
}, [history, presetPrompts]);
|
||||
|
||||
// 清理定时器
|
||||
|
||||
@@ -97,9 +97,9 @@ export const EnhancedChatInput = forwardRef<EnhancedChatInputRef, EnhancedChatIn
|
||||
parts,
|
||||
};
|
||||
|
||||
// 保存提示词到历史记录
|
||||
// 保存提示词到历史记录(Chat Drawer 默认为 Agent 模式)
|
||||
if (trimmedInput) {
|
||||
addPromptHistory(trimmedInput, hasSelection);
|
||||
addPromptHistory(trimmedInput, hasSelection, 'agent');
|
||||
}
|
||||
|
||||
onSend(message);
|
||||
|
||||
@@ -125,6 +125,32 @@ function updateWorkflowStepForTask(
|
||||
);
|
||||
|
||||
if (allStepsFinished) {
|
||||
// 检查是否所有步骤都已完成或失败
|
||||
const hasQueuedTasks = updatedSteps.some(step => {
|
||||
const stepResult = step.result as { taskId?: string } | undefined;
|
||||
return !!stepResult?.taskId;
|
||||
});
|
||||
|
||||
console.log(`[AutoInsert] WorkZone ${workzone.id} allStepsFinished: ${allStepsFinished}, hasQueuedTasks: ${hasQueuedTasks}`);
|
||||
|
||||
// 如果有队列任务(图片/视频生成),检查后处理是否完成
|
||||
if (hasQueuedTasks) {
|
||||
const allPostProcessingFinished = updatedSteps.every(step => {
|
||||
const stepResult = step.result as { taskId?: string } | undefined;
|
||||
if (stepResult?.taskId) {
|
||||
const isCompleted = workflowCompletionService.isPostProcessingCompleted(stepResult.taskId);
|
||||
console.log(`[AutoInsert] Task ${stepResult.taskId} post-processing completed: ${isCompleted}`);
|
||||
return isCompleted;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!allPostProcessingFinished) {
|
||||
console.log(`[AutoInsert] WorkZone ${workzone.id} has unfinished post-processing, skipping removal`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟删除 WorkZone,让用户有时间看到完成状态
|
||||
setTimeout(() => {
|
||||
WorkZoneTransforms.removeWorkZone(board, workzone.id);
|
||||
@@ -453,10 +479,55 @@ export function useAutoInsertToCanvas(config: Partial<AutoInsertConfig> = {}): v
|
||||
}
|
||||
});
|
||||
|
||||
// 订阅后处理完成事件,以便在所有任务插入完成后删除 WorkZone
|
||||
const completionSub = workflowCompletionService.observeCompletionEvents().subscribe(event => {
|
||||
console.log(`[AutoInsert] Received completion event: ${event.type} for task ${event.taskId}`);
|
||||
if (event.type === 'postProcessingCompleted' || event.type === 'postProcessingFailed') {
|
||||
const board = getCanvasBoard();
|
||||
if (!board) return;
|
||||
|
||||
const workzone = findWorkZoneForTask(event.taskId);
|
||||
if (workzone) {
|
||||
console.log(`[AutoInsert] Found WorkZone ${workzone.id} for task ${event.taskId}`);
|
||||
// 重新检查该 WorkZone 的所有步骤
|
||||
const allStepsFinished = workzone.workflow.steps?.every(
|
||||
step => step.status === 'completed' || step.status === 'failed' || step.status === 'skipped'
|
||||
);
|
||||
|
||||
if (allStepsFinished) {
|
||||
const allPostProcessingFinished = workzone.workflow.steps?.every(step => {
|
||||
const stepResult = step.result as { taskId?: string } | undefined;
|
||||
if (stepResult?.taskId) {
|
||||
const isCompleted = workflowCompletionService.isPostProcessingCompleted(stepResult.taskId);
|
||||
console.log(`[AutoInsert] Step task ${stepResult.taskId} isCompleted: ${isCompleted}`);
|
||||
return isCompleted;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log(`[AutoInsert] WorkZone ${workzone.id} allStepsFinished: ${allStepsFinished}, allPostProcessingFinished: ${allPostProcessingFinished}`);
|
||||
|
||||
if (allPostProcessingFinished) {
|
||||
console.log(`[AutoInsert] All post-processing finished for WorkZone ${workzone.id}, removing`);
|
||||
setTimeout(() => {
|
||||
WorkZoneTransforms.removeWorkZone(board, workzone.id);
|
||||
window.dispatchEvent(new CustomEvent('ai-generation-complete', {
|
||||
detail: { type: 'image', success: true, workzoneId: workzone.id }
|
||||
}));
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[AutoInsert] No WorkZone found for task ${event.taskId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
isActive = false;
|
||||
subscription.unsubscribe();
|
||||
completionSub.unsubscribe();
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface UsePromptHistoryReturn {
|
||||
/** 历史提示词列表(已去重) */
|
||||
history: PromptHistoryItem[];
|
||||
/** 添加历史记录 */
|
||||
addHistory: (content: string, hasSelection?: boolean) => void;
|
||||
addHistory: (content: string, hasSelection?: boolean, modelType?: 'image' | 'video' | 'agent') => void;
|
||||
/** 删除指定历史记录 */
|
||||
removeHistory: (id: string) => void;
|
||||
/** 清空所有历史记录 */
|
||||
@@ -95,8 +95,8 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
|
||||
}, [refreshHistory]);
|
||||
|
||||
// 添加历史记录
|
||||
const addHistory = useCallback((content: string, hasSelection?: boolean) => {
|
||||
promptStorageService.addHistory(content, hasSelection);
|
||||
const addHistory = useCallback((content: string, hasSelection?: boolean, modelType?: 'image' | 'video' | 'agent') => {
|
||||
promptStorageService.addHistory(content, hasSelection, modelType);
|
||||
refreshHistory();
|
||||
}, [refreshHistory]);
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ export function useWorkflowSubmission(
|
||||
event: WorkflowEvent,
|
||||
legacyWorkflow: LegacyWorkflowDefinition,
|
||||
retryContext: WorkflowRetryContext
|
||||
) => void) | null>(null);
|
||||
) => Promise<void>) | null>(null);
|
||||
|
||||
/**
|
||||
* Recover workflows on mount (after page refresh)
|
||||
@@ -265,8 +265,8 @@ export function useWorkflowSubmission(
|
||||
// Subscribe to future events for this workflow using ref
|
||||
const workflowSub = workflowSubmissionService.subscribeToWorkflow(
|
||||
recoveredWorkflow.id,
|
||||
(evt: WorkflowEvent) => {
|
||||
handleWorkflowEventRef.current?.(evt, recoveredWorkflow, retryContext);
|
||||
async (evt: WorkflowEvent) => {
|
||||
await handleWorkflowEventRef.current?.(evt, recoveredWorkflow, retryContext);
|
||||
}
|
||||
);
|
||||
subscriptionsRef.current.push(workflowSub);
|
||||
@@ -315,7 +315,7 @@ export function useWorkflowSubmission(
|
||||
/**
|
||||
* Handle workflow events from SW
|
||||
*/
|
||||
const handleWorkflowEvent = useCallback((
|
||||
const handleWorkflowEvent = useCallback(async (
|
||||
event: WorkflowEvent,
|
||||
legacyWorkflow: LegacyWorkflowDefinition,
|
||||
retryContext: WorkflowRetryContext
|
||||
@@ -349,7 +349,7 @@ export function useWorkflowSubmission(
|
||||
}
|
||||
|
||||
case 'completed': {
|
||||
// console.log('[useWorkflowSubmission] ✓ Workflow completed:', event.workflowId);
|
||||
console.log('[useWorkflowSubmission] ✓ Workflow completed:', event.workflowId);
|
||||
|
||||
// Update steps to completed status, but skip steps with taskId (they're waiting for task completion)
|
||||
const currentWorkflow = workflowControl.getWorkflow();
|
||||
@@ -358,16 +358,18 @@ export function useWorkflowSubmission(
|
||||
if (currentWorkflow) {
|
||||
// Mark remaining steps as completed, except those with taskId (queue tasks)
|
||||
currentWorkflow.steps.forEach(step => {
|
||||
if (step.status === 'running' || step.status === 'pending') {
|
||||
// 如果步骤有 taskId,说明是队列任务,不要强制标记为 completed
|
||||
// 等待任务真正完成后由 taskQueueService 更新状态
|
||||
const stepResult = step.result as { taskId?: string } | undefined;
|
||||
const hasTaskId = stepResult?.taskId;
|
||||
if (hasTaskId) {
|
||||
hasQueuedTasks = true;
|
||||
} else {
|
||||
workflowControl.updateStep(step.id, 'completed');
|
||||
}
|
||||
const stepResult = step.result as { taskId?: string } | undefined;
|
||||
const hasTaskId = stepResult?.taskId;
|
||||
|
||||
// 检查所有状态的步骤是否有 taskId
|
||||
if (hasTaskId) {
|
||||
hasQueuedTasks = true;
|
||||
// console.log('[useWorkflowSubmission] Step has taskId:', step.id, step.mcp, 'taskId:', hasTaskId);
|
||||
}
|
||||
|
||||
// 只更新 running/pending 状态的步骤(如果没有 taskId 的话)
|
||||
if ((step.status === 'running' || step.status === 'pending') && !hasTaskId) {
|
||||
workflowControl.updateStep(step.id, 'completed');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -381,13 +383,36 @@ export function useWorkflowSubmission(
|
||||
if (workZoneId && board) {
|
||||
WorkZoneTransforms.updateWorkflow(board, workZoneId, workflowData);
|
||||
|
||||
console.log('[useWorkflowSubmission] Checking removal for WorkZone:', workZoneId, 'hasQueuedTasks:', hasQueuedTasks);
|
||||
|
||||
// If no queued tasks (like generate_image), remove WorkZone after a delay
|
||||
// Queued tasks will be handled by useAutoInsertToCanvas when they complete
|
||||
// Queued tasks will be handled by useAutoInsertToCanvas when they complete AND are inserted
|
||||
if (!hasQueuedTasks) {
|
||||
setTimeout(() => {
|
||||
WorkZoneTransforms.removeWorkZone(board, workZoneId);
|
||||
// console.log('[useWorkflowSubmission] Removed WorkZone after completion:', workZoneId);
|
||||
console.log('[useWorkflowSubmission] Removed WorkZone after completion:', workZoneId);
|
||||
}, 1500);
|
||||
} else {
|
||||
// 检查是否所有队列任务的后处理已经完成(可能在工作流完成前就已经插入了)
|
||||
const { workflowCompletionService } = await import('../services/workflow-completion-service');
|
||||
const allPostProcessingFinished = completedWorkflow.steps.every(step => {
|
||||
const stepResult = step.result as { taskId?: string } | undefined;
|
||||
if (stepResult?.taskId) {
|
||||
const isCompleted = workflowCompletionService.isPostProcessingCompleted(stepResult.taskId);
|
||||
console.log(`[useWorkflowSubmission] Task ${stepResult.taskId} post-processing finished:`, isCompleted);
|
||||
return isCompleted;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (allPostProcessingFinished) {
|
||||
console.log('[useWorkflowSubmission] All post-processing finished, removing WorkZone:', workZoneId);
|
||||
setTimeout(() => {
|
||||
WorkZoneTransforms.removeWorkZone(board, workZoneId);
|
||||
}, 1500);
|
||||
} else {
|
||||
console.log('[useWorkflowSubmission] Post-processing still in progress, keeping WorkZone:', workZoneId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,8 +522,8 @@ export function useWorkflowSubmission(
|
||||
// Subscribe to workflow events
|
||||
const workflowSub = workflowSubmissionService.subscribeToWorkflow(
|
||||
swWorkflow.id,
|
||||
(event: WorkflowEvent) => {
|
||||
handleWorkflowEvent(event, legacyWorkflow, retryContext);
|
||||
async (event: WorkflowEvent) => {
|
||||
await handleWorkflowEvent(event, legacyWorkflow, retryContext);
|
||||
}
|
||||
);
|
||||
subscriptionsRef.current.push(workflowSub);
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PromptHistoryItem {
|
||||
hasSelection?: boolean;
|
||||
/** 是否置顶 */
|
||||
pinned?: boolean;
|
||||
/** 生成类型:image(直接生图)、video(直接生视频)、agent(需要Agent分析) */
|
||||
modelType?: 'image' | 'video' | 'agent';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,8 +199,13 @@ export function getPromptHistory(): PromptHistoryItem[] {
|
||||
* 注意:如果相同内容已被置顶,只更新时间戳,不会创建新记录
|
||||
* @param content 提示词内容
|
||||
* @param hasSelection 是否在有选中元素时输入的
|
||||
* @param modelType 生成类型:image、video 或 agent
|
||||
*/
|
||||
export function addPromptHistory(content: string, hasSelection?: boolean): void {
|
||||
export function addPromptHistory(
|
||||
content: string,
|
||||
hasSelection?: boolean,
|
||||
modelType?: 'image' | 'video' | 'agent'
|
||||
): void {
|
||||
if (!content || !content.trim()) return;
|
||||
|
||||
const trimmedContent = content.trim();
|
||||
@@ -212,8 +219,11 @@ export function addPromptHistory(content: string, hasSelection?: boolean): void
|
||||
if (existingIndex >= 0) {
|
||||
const existingItem = history[existingIndex];
|
||||
if (existingItem.pinned) {
|
||||
// 已置顶的提示词:只更新时间戳,保持置顶状态
|
||||
// 已置顶的提示词:更新时间戳和 modelType,保持置顶状态
|
||||
existingItem.timestamp = Date.now();
|
||||
if (modelType) {
|
||||
existingItem.modelType = modelType;
|
||||
}
|
||||
promptHistoryCache = history;
|
||||
savePromptHistory();
|
||||
return;
|
||||
@@ -228,6 +238,7 @@ export function addPromptHistory(content: string, hasSelection?: boolean): void
|
||||
content: trimmedContent,
|
||||
timestamp: Date.now(),
|
||||
hasSelection,
|
||||
modelType,
|
||||
};
|
||||
history.unshift(newItem);
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@ export interface ImageElement {
|
||||
width: number;
|
||||
/** 原始高度 */
|
||||
height: number;
|
||||
/** 在原图中的 X 坐标(用于按原图位置布局) */
|
||||
sourceX?: number;
|
||||
/** 在原图中的 Y 坐标(用于按原图位置布局) */
|
||||
sourceY?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface GridDetectionResult {
|
||||
/**
|
||||
* 检测像素是否为白色/浅色(分割线颜色)
|
||||
*/
|
||||
function isLightPixel(r: number, g: number, b: number, threshold: number = 240): boolean {
|
||||
function isLightPixel(r: number, g: number, b: number, threshold = 240): boolean {
|
||||
// 检查是否接近白色
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
@@ -57,7 +57,7 @@ function isLightPixel(r: number, g: number, b: number, threshold: number = 240):
|
||||
/**
|
||||
* 检测像素是否为透明(用于检测合并图片的透明区域)
|
||||
*/
|
||||
function isTransparentPixel(a: number, threshold: number = 50): boolean {
|
||||
function isTransparentPixel(a: number, threshold = 50): boolean {
|
||||
// Alpha 值小于阈值认为是透明
|
||||
return a < threshold;
|
||||
}
|
||||
@@ -136,7 +136,7 @@ function getColTransparentRatio(imageData: ImageData, x: number): number {
|
||||
function isHorizontalSplitLine(
|
||||
imageData: ImageData,
|
||||
y: number,
|
||||
minWhiteRatio: number = 0.90
|
||||
minWhiteRatio = 0.90
|
||||
): boolean {
|
||||
return getRowWhiteRatio(imageData, y) >= minWhiteRatio;
|
||||
}
|
||||
@@ -148,7 +148,7 @@ function isHorizontalSplitLine(
|
||||
function isHorizontalTransparentLine(
|
||||
imageData: ImageData,
|
||||
y: number,
|
||||
minTransparentRatio: number = 1.0 // 必须 100% 透明
|
||||
minTransparentRatio = 1.0 // 必须 100% 透明
|
||||
): boolean {
|
||||
return getRowTransparentRatio(imageData, y) >= minTransparentRatio;
|
||||
}
|
||||
@@ -159,7 +159,7 @@ function isHorizontalTransparentLine(
|
||||
function isVerticalSplitLine(
|
||||
imageData: ImageData,
|
||||
x: number,
|
||||
minWhiteRatio: number = 0.90
|
||||
minWhiteRatio = 0.90
|
||||
): boolean {
|
||||
return getColWhiteRatio(imageData, x) >= minWhiteRatio;
|
||||
}
|
||||
@@ -171,7 +171,7 @@ function isVerticalSplitLine(
|
||||
function isVerticalTransparentLine(
|
||||
imageData: ImageData,
|
||||
x: number,
|
||||
minTransparentRatio: number = 1.0 // 必须 100% 透明
|
||||
minTransparentRatio = 1.0 // 必须 100% 透明
|
||||
): boolean {
|
||||
return getColTransparentRatio(imageData, x) >= minTransparentRatio;
|
||||
}
|
||||
@@ -179,7 +179,7 @@ function isVerticalTransparentLine(
|
||||
/**
|
||||
* 合并相邻的分割线位置
|
||||
*/
|
||||
function mergeSplitLines(lines: number[], minGap: number = 10): number[] {
|
||||
function mergeSplitLines(lines: number[], minGap = 10): number[] {
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const sorted = [...lines].sort((a, b) => a - b);
|
||||
@@ -351,7 +351,7 @@ function verifyGridConfig(
|
||||
* @param minWidth - 最小宽度(默认 3 像素)
|
||||
* @returns 有效的分割线位置
|
||||
*/
|
||||
function validateSplitLineWidth(lines: number[], minWidth: number = 3): number[] {
|
||||
function validateSplitLineWidth(lines: number[], minWidth = 3): number[] {
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const sorted = [...lines].sort((a, b) => a - b);
|
||||
@@ -673,8 +673,8 @@ function getStrictWhiteBorders(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
lightnessThreshold: number = 220,
|
||||
alphaThreshold: number = 30
|
||||
lightnessThreshold = 220,
|
||||
alphaThreshold = 30
|
||||
): { top: number; right: number; bottom: number; left: number } {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
@@ -781,60 +781,27 @@ async function splitUniformGrid(
|
||||
const sx = col * cellWidth;
|
||||
const sy = row * cellHeight;
|
||||
|
||||
// 创建临时 Canvas 用于裁剪
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = cellWidth;
|
||||
tempCanvas.height = cellHeight;
|
||||
// 创建 Canvas 用于裁剪
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = cellWidth;
|
||||
canvas.height = cellHeight;
|
||||
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempCtx) continue;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
|
||||
// 先裁剪到临时 Canvas
|
||||
tempCtx.drawImage(img, sx, sy, cellWidth, cellHeight, 0, 0, cellWidth, cellHeight);
|
||||
// 精确裁剪,不做去白边处理
|
||||
// 标准宫格的分割线已经在外围去白边时处理过了
|
||||
ctx.drawImage(img, sx, sy, cellWidth, cellHeight, 0, 0, cellWidth, cellHeight);
|
||||
|
||||
// 获取严格的白边边界
|
||||
const borders = getStrictWhiteBorders(tempCtx, cellWidth, cellHeight);
|
||||
|
||||
// 计算去白边后的尺寸
|
||||
const trimmedWidth = borders.right - borders.left + 1;
|
||||
const trimmedHeight = borders.bottom - borders.top + 1;
|
||||
|
||||
// 如果有白边需要裁剪,创建新的 Canvas
|
||||
if (trimmedWidth < cellWidth || trimmedHeight < cellHeight) {
|
||||
const finalCanvas = document.createElement('canvas');
|
||||
finalCanvas.width = trimmedWidth;
|
||||
finalCanvas.height = trimmedHeight;
|
||||
|
||||
const finalCtx = finalCanvas.getContext('2d');
|
||||
if (!finalCtx) continue;
|
||||
|
||||
finalCtx.drawImage(
|
||||
tempCanvas,
|
||||
borders.left, borders.top, trimmedWidth, trimmedHeight,
|
||||
0, 0, trimmedWidth, trimmedHeight
|
||||
);
|
||||
|
||||
elements.push({
|
||||
imageData: finalCanvas.toDataURL('image/jpeg', 0.92),
|
||||
index,
|
||||
width: trimmedWidth,
|
||||
height: trimmedHeight,
|
||||
sourceX: sx + borders.left,
|
||||
sourceY: sy + borders.top,
|
||||
hasTransparency: forceTransparency, // 传递透明度标记
|
||||
});
|
||||
} else {
|
||||
// 没有白边,直接使用
|
||||
elements.push({
|
||||
imageData: tempCanvas.toDataURL('image/jpeg', 0.92),
|
||||
index,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
hasTransparency: forceTransparency, // 传递透明度标记
|
||||
});
|
||||
}
|
||||
elements.push({
|
||||
imageData: canvas.toDataURL('image/jpeg', 0.92),
|
||||
index,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
hasTransparency: forceTransparency,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,19 +818,31 @@ async function splitUniformGrid(
|
||||
export async function splitImageByLines(
|
||||
imageUrl: string,
|
||||
detection: GridDetectionResult,
|
||||
forceTransparency?: boolean
|
||||
forceTransparency?: boolean,
|
||||
options?: { skipPadding?: boolean }
|
||||
): Promise<SplitImageElement[]> {
|
||||
const img = await loadImage(imageUrl);
|
||||
const { naturalWidth: width, naturalHeight: height } = img;
|
||||
|
||||
// 检测是否为标准宫格布局
|
||||
const isUniformGrid = isUniformGridLayout(detection, width, height);
|
||||
|
||||
console.log('[splitImageByLines] isUniformGrid:', isUniformGrid, {
|
||||
rows: detection.rows,
|
||||
cols: detection.cols,
|
||||
rowLines: detection.rowLines,
|
||||
colLines: detection.colLines,
|
||||
imageSize: { width, height },
|
||||
});
|
||||
|
||||
// 对于标准宫格,使用精确等分方式(类似 gridSplitter.split)
|
||||
// 注意:调用前已完成去白边处理
|
||||
if (isUniformGrid) {
|
||||
console.log('[splitImageByLines] Using splitUniformGrid (no trim)');
|
||||
return splitUniformGrid(img, detection.rows, detection.cols, forceTransparency);
|
||||
}
|
||||
|
||||
console.log('[splitImageByLines] Using line-based split (non-uniform)');
|
||||
|
||||
// 非标准布局:使用检测到的分割线位置进行分割
|
||||
const rowBounds = [0, ...detection.rowLines, height];
|
||||
@@ -899,13 +878,20 @@ export async function splitImageByLines(
|
||||
|
||||
// 根据图片类型选择 padding
|
||||
// 透明图片:不使用 padding,因为透明分割线检测已经足够精确(100% 透明)
|
||||
// 普通图片:使用较大的 padding 以确保裁剪干净
|
||||
const splitLinePadding = hasAlpha ? 0 : 8;
|
||||
// 标准宫格(skipPadding):不使用 padding,避免裁掉图片内容中的白色部分
|
||||
// 普通图片:使用较小的 padding(分割线通常较窄)
|
||||
const skipPadding = options?.skipPadding ?? false;
|
||||
const splitLinePadding = (hasAlpha || skipPadding) ? 0 : 2;
|
||||
|
||||
let sx = x1 + (col > 0 ? splitLinePadding : 0);
|
||||
let sy = y1 + (row > 0 ? splitLinePadding : 0);
|
||||
let sw = x2 - x1 - (col > 0 ? splitLinePadding : 0) - (col < colBounds.length - 2 ? splitLinePadding : 0);
|
||||
let sh = y2 - y1 - (row > 0 ? splitLinePadding : 0) - (row < rowBounds.length - 2 ? splitLinePadding : 0);
|
||||
const sx = x1 + (col > 0 ? splitLinePadding : 0);
|
||||
const sy = y1 + (row > 0 ? splitLinePadding : 0);
|
||||
const sw = x2 - x1 - (col > 0 ? splitLinePadding : 0) - (col < colBounds.length - 2 ? splitLinePadding : 0);
|
||||
const sh = y2 - y1 - (row > 0 ? splitLinePadding : 0) - (row < rowBounds.length - 2 ? splitLinePadding : 0);
|
||||
|
||||
// 首次调用时打印 padding 信息
|
||||
if (index === 0) {
|
||||
console.log('[splitImageByLines] Padding:', { splitLinePadding, skipPadding, hasAlpha });
|
||||
}
|
||||
|
||||
if (sw <= 0 || sh <= 0) continue;
|
||||
|
||||
@@ -924,18 +910,16 @@ export async function splitImageByLines(
|
||||
// 透明图片:使用严格模式裁剪透明边框
|
||||
// 只去除完全透明(alpha=0)的边缘,保留任何包含非透明像素的区域
|
||||
borders = trimTransparentBorders(regionData, true);
|
||||
// console.log('[splitImageByLines] Transparent image: strict transparent border trim', {
|
||||
// row, col,
|
||||
// originalSize: { width: sw, height: sh },
|
||||
// borders,
|
||||
// trimmedSize: {
|
||||
// width: borders.right - borders.left + 1,
|
||||
// height: borders.bottom - borders.top + 1
|
||||
// }
|
||||
// });
|
||||
} else if (skipPadding) {
|
||||
// 标准宫格:使用最严格的去白边
|
||||
// borderRatio: 1.0 表示整行/整列 100% 白色才认为是白边
|
||||
// maxTrimRatio: 0.05 表示最多裁剪 5%
|
||||
borders = trimBorders(regionData, 1.0, 0.05);
|
||||
} else {
|
||||
// 普通图片:裁剪白边和灰边
|
||||
borders = trimBorders(regionData, 0.5, 0.15);
|
||||
// 普通图片(灵感图等):裁剪白边,使用严格阈值
|
||||
// borderRatio: 0.95 表示 95% 的像素都是白色才认为是白边
|
||||
// maxTrimRatio: 0.05 表示最多裁剪 5%
|
||||
borders = trimBorders(regionData, 0.95, 0.05);
|
||||
}
|
||||
|
||||
// 计算最终裁剪区域
|
||||
@@ -980,7 +964,7 @@ export async function splitImageByLines(
|
||||
* 检测像素是否为白色/浅色(只检测白色,不检测黑色)
|
||||
* 专门用于宫格图去白边,避免误裁黑色内容
|
||||
*/
|
||||
function isWhitePixel(r: number, g: number, b: number, threshold: number = 235): boolean {
|
||||
function isWhitePixel(r: number, g: number, b: number, threshold = 235): boolean {
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
|
||||
@@ -992,8 +976,8 @@ function isWhitePixel(r: number, g: number, b: number, threshold: number = 235):
|
||||
*/
|
||||
function trimWhiteBordersOnly(
|
||||
imageData: ImageData,
|
||||
borderRatio: number = 0.7,
|
||||
maxTrimRatio: number = 0.15
|
||||
borderRatio = 0.7,
|
||||
maxTrimRatio = 0.15
|
||||
): { top: number; right: number; bottom: number; left: number } {
|
||||
const { width, height, data } = imageData;
|
||||
|
||||
@@ -1076,7 +1060,7 @@ function trimWhiteBordersOnly(
|
||||
*/
|
||||
function trimTransparentBorders(
|
||||
imageData: ImageData,
|
||||
strict: boolean = true
|
||||
strict = true
|
||||
): { top: number; right: number; bottom: number; left: number } {
|
||||
const { width, height, data } = imageData;
|
||||
|
||||
@@ -1280,8 +1264,8 @@ const MIN_SPLIT_SIZE = 100;
|
||||
|
||||
export async function recursiveSplitElement(
|
||||
element: SplitImageElement,
|
||||
depth: number = 0,
|
||||
maxDepth: number = 2,
|
||||
depth = 0,
|
||||
maxDepth = 2,
|
||||
options?: { skipTrim?: boolean }
|
||||
): Promise<SplitImageElement[]> {
|
||||
// 防止无限递归
|
||||
@@ -1298,11 +1282,15 @@ export async function recursiveSplitElement(
|
||||
// - 原图(depth=0 且未指定 skipTrim):需要去白边
|
||||
// - 宫格子图(skipTrim=true):不需要去白边,避免裁掉人物内容
|
||||
const skipTrim = options?.skipTrim ?? false;
|
||||
|
||||
console.log(`[recursiveSplitElement] depth=${depth}, skipTrim=${skipTrim}, size=${element.width}x${element.height}`);
|
||||
|
||||
let workingImageUrl = element.imageData;
|
||||
let workingWidth = element.width;
|
||||
let workingHeight = element.height;
|
||||
let hasTrimmed = false;
|
||||
let trimOffsetX = 0; // 去白边后的左边偏移
|
||||
let trimOffsetY = 0; // 去白边后的上边偏移
|
||||
|
||||
if (!skipTrim) {
|
||||
// 前置步骤:先去除白边
|
||||
@@ -1311,6 +1299,8 @@ export async function recursiveSplitElement(
|
||||
workingWidth = borderInfo.trimmed.width;
|
||||
workingHeight = borderInfo.trimmed.height;
|
||||
hasTrimmed = borderInfo.hasBorders;
|
||||
trimOffsetX = borderInfo.borders.left;
|
||||
trimOffsetY = borderInfo.borders.top;
|
||||
|
||||
// 去白边后尺寸太小,不再拆分
|
||||
if (workingWidth < MIN_SPLIT_SIZE || workingHeight < MIN_SPLIT_SIZE) {
|
||||
@@ -1320,6 +1310,9 @@ export async function recursiveSplitElement(
|
||||
imageData: workingImageUrl,
|
||||
width: workingWidth,
|
||||
height: workingHeight,
|
||||
// 调整位置:加上去白边的偏移
|
||||
sourceX: element.sourceX + trimOffsetX,
|
||||
sourceY: element.sourceY + trimOffsetY,
|
||||
}];
|
||||
}
|
||||
return [element];
|
||||
@@ -1338,6 +1331,9 @@ export async function recursiveSplitElement(
|
||||
imageData: workingImageUrl,
|
||||
width: workingWidth,
|
||||
height: workingHeight,
|
||||
// 调整位置:加上去白边的偏移
|
||||
sourceX: element.sourceX + trimOffsetX,
|
||||
sourceY: element.sourceY + trimOffsetY,
|
||||
}];
|
||||
}
|
||||
return [element];
|
||||
@@ -1361,22 +1357,35 @@ export async function recursiveSplitElement(
|
||||
return [element];
|
||||
}
|
||||
|
||||
// 计算子元素位置时需要累积父元素的位置
|
||||
// 子元素的 sourceX/sourceY 是相对于 workingImageUrl(去白边后的图片)的
|
||||
// 需要加上:1) 父元素在原图中的位置 2) 去白边的偏移
|
||||
const parentOffsetX = element.sourceX + trimOffsetX;
|
||||
const parentOffsetY = element.sourceY + trimOffsetY;
|
||||
|
||||
// 将子元素的位置转换为相对于原始图片的绝对位置
|
||||
const adjustedSubElements = subElements.map(subEl => ({
|
||||
...subEl,
|
||||
sourceX: subEl.sourceX + parentOffsetX,
|
||||
sourceY: subEl.sourceY + parentOffsetY,
|
||||
}));
|
||||
|
||||
// 标准宫格:直接返回拆分结果,不再递归
|
||||
// 子图不需要再去白边,避免裁掉人物内容
|
||||
if (isStandardGrid) {
|
||||
return subElements;
|
||||
return adjustedSubElements;
|
||||
}
|
||||
|
||||
// 非标准布局:继续递归处理每个子元素
|
||||
// 非标准布局的子元素仍然需要去白边(可能包含嵌套的白边图片)
|
||||
const allResults: SplitImageElement[] = [];
|
||||
for (const subEl of subElements) {
|
||||
for (const subEl of adjustedSubElements) {
|
||||
// 子元素太小,直接添加
|
||||
if (subEl.width < MIN_SPLIT_SIZE || subEl.height < MIN_SPLIT_SIZE) {
|
||||
allResults.push(subEl);
|
||||
continue;
|
||||
}
|
||||
const recursiveResults = await recursiveSplitElement(subEl, depth + 1, maxDepth);
|
||||
const recursiveResults = await recursiveSplitElement(subEl, depth + 1, maxDepth, options);
|
||||
allResults.push(...recursiveResults);
|
||||
}
|
||||
|
||||
@@ -1458,16 +1467,14 @@ export async function splitAndInsertImages(
|
||||
let elements: SplitImageElement[] = [];
|
||||
// 标记是否为标准宫格(用于决定是否跳过子图去白边)
|
||||
let isStandardGrid = false;
|
||||
// 用于计算缩放的图片尺寸
|
||||
let workingImageWidth: number;
|
||||
let workingImageHeight: number;
|
||||
|
||||
// 策略:无论什么格式,都先去除外围白边
|
||||
// 外围白边会干扰网格检测,必须先清理
|
||||
// 注意:这里去除的是整张图片的外围白边,不是内部分割线
|
||||
const { trimmedImageUrl, borderInfo } = await trimImageWhiteBorders(imageUrl);
|
||||
workingImageWidth = borderInfo.trimmed.width;
|
||||
workingImageHeight = borderInfo.trimmed.height;
|
||||
// 用于计算缩放的图片尺寸
|
||||
const workingImageWidth = borderInfo.trimmed.width;
|
||||
const workingImageHeight = borderInfo.trimmed.height;
|
||||
|
||||
// console.log('[splitAndInsertImages] Trimmed outer border:', {
|
||||
// original: borderInfo.original,
|
||||
@@ -1479,27 +1486,34 @@ export async function splitAndInsertImages(
|
||||
// 使用 detectGridLinesInternal 以获取透明度信息
|
||||
const { detection, hasTransparency } = await detectGridLinesInternal(trimmedImageUrl);
|
||||
|
||||
// console.log('[splitAndInsertImages] Grid detection:', {
|
||||
// rows: detection.rows,
|
||||
// cols: detection.cols,
|
||||
// hasTransparency,
|
||||
// });
|
||||
console.log('[splitAndInsertImages] Grid detection:', {
|
||||
rows: detection.rows,
|
||||
cols: detection.cols,
|
||||
hasTransparency,
|
||||
rowLines: detection.rowLines?.length,
|
||||
colLines: detection.colLines?.length,
|
||||
});
|
||||
|
||||
if (detection.rows > 1 || detection.cols > 1) {
|
||||
// 网格分割线格式 - 使用去除外围白边后的图片
|
||||
// 传递透明度标记给 splitImageByLines
|
||||
const initialElements = await splitImageByLines(trimmedImageUrl, detection, hasTransparency);
|
||||
|
||||
// 判断是否为标准宫格(行列数都大于1,说明是规则宫格)
|
||||
isStandardGrid = detection.rows > 1 && detection.cols > 1;
|
||||
|
||||
// 网格分割线格式 - 使用去除外围白边后的图片
|
||||
// 传递透明度标记给 splitImageByLines
|
||||
// 对于标准宫格,禁用 padding 以避免裁掉图片内容中的白色部分
|
||||
const initialElements = await splitImageByLines(
|
||||
trimmedImageUrl,
|
||||
detection,
|
||||
hasTransparency,
|
||||
{ skipPadding: isStandardGrid }
|
||||
);
|
||||
|
||||
// console.log('[splitAndInsertImages] Grid detected:', {
|
||||
// rows: detection.rows,
|
||||
// cols: detection.cols,
|
||||
// isStandardGrid,
|
||||
// hasTransparency,
|
||||
// elementsCount: initialElements.length,
|
||||
// });
|
||||
console.log('[splitAndInsertImages] Grid detected:', {
|
||||
rows: detection.rows,
|
||||
cols: detection.cols,
|
||||
isStandardGrid,
|
||||
elementsCount: initialElements.length,
|
||||
});
|
||||
|
||||
// 对于透明背景的合并图片,禁用递归拆分
|
||||
// 因为文字行之间的透明间隙会导致文字被切成碎片
|
||||
@@ -1527,7 +1541,10 @@ export async function splitAndInsertImages(
|
||||
continue;
|
||||
}
|
||||
|
||||
const recursiveResults = await recursiveSplitElement(el, 0, 2);
|
||||
// 递归拆分时禁用去白边(skipTrim: true),因为 splitImageByLines 已经精确切割了
|
||||
// 再去白边会导致子图比原图小
|
||||
console.log('[splitAndInsertImages] Recursive split with skipTrim: true');
|
||||
const recursiveResults = await recursiveSplitElement(el, 0, 2, { skipTrim: true });
|
||||
// 限制添加的数量
|
||||
const remainingSlots = MAX_TOTAL_ELEMENTS - elements.length;
|
||||
elements.push(...recursiveResults.slice(0, remainingSlots));
|
||||
@@ -1536,20 +1553,27 @@ export async function splitAndInsertImages(
|
||||
} else {
|
||||
// 尝试灵感图格式(已内置递归拆分)- 使用去除白边后的图片
|
||||
const isPhotoWall = await detectPhotoWallFormat(trimmedImageUrl);
|
||||
console.log('[splitAndInsertImages] PhotoWall detection:', { isPhotoWall });
|
||||
|
||||
if (isPhotoWall) {
|
||||
const { splitPhotoWall } = await import('./photo-wall-splitter');
|
||||
const inspirationBoardElements = await splitPhotoWall(trimmedImageUrl);
|
||||
|
||||
// 转换为 SplitImageElement 格式
|
||||
// 转换为 SplitImageElement 格式,保留原图位置信息
|
||||
console.log('[splitAndInsertImages] Inspiration board raw elements:',
|
||||
inspirationBoardElements.map(el => ({ sourceX: el.sourceX, sourceY: el.sourceY, w: el.width, h: el.height })));
|
||||
|
||||
elements = inspirationBoardElements.map((el, index) => ({
|
||||
imageData: el.imageData,
|
||||
index,
|
||||
width: el.width,
|
||||
height: el.height,
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
sourceX: el.sourceX ?? 0,
|
||||
sourceY: el.sourceY ?? 0,
|
||||
}));
|
||||
|
||||
console.log('[splitAndInsertImages] Converted elements:',
|
||||
elements.map(el => ({ sourceX: el.sourceX, sourceY: el.sourceY })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1561,21 +1585,9 @@ export async function splitAndInsertImages(
|
||||
};
|
||||
}
|
||||
|
||||
// 2.5 对非标准宫格的子图进行去白边处理
|
||||
// 标准宫格使用精确等分,不需要去白边,避免裁掉白色背景人物图的边缘(手、头发等)
|
||||
if (!isStandardGrid) {
|
||||
const cleanedElements: SplitImageElement[] = [];
|
||||
for (const el of elements) {
|
||||
const { trimmedImageUrl: elTrimmedUrl, borderInfo: elBorderInfo } = await trimImageWhiteBorders(el.imageData);
|
||||
cleanedElements.push({
|
||||
...el,
|
||||
imageData: elTrimmedUrl,
|
||||
width: elBorderInfo.trimmed.width,
|
||||
height: elBorderInfo.trimmed.height,
|
||||
});
|
||||
}
|
||||
elements = cleanedElements;
|
||||
}
|
||||
// 2.5 注意:不再对非标准宫格的子图进行额外去白边处理
|
||||
// 原因:递归拆分(recursiveSplitElement)已经做了去白边处理,再做一次会切掉内容
|
||||
// 标准宫格使用精确等分,也不需要去白边
|
||||
|
||||
// 重新分配 index
|
||||
elements = elements.map((el, idx) => ({ ...el, index: idx }));
|
||||
@@ -1615,27 +1627,117 @@ export async function splitAndInsertImages(
|
||||
}
|
||||
|
||||
// 5. 根据子图片在原图中的相对位置插入
|
||||
// 保持原图的布局结构
|
||||
// 如果所有子图片都没有有效位置信息(sourceX 和 sourceY 都为 0),则使用网格布局
|
||||
const hasValidPositions = elements.some(el => el.sourceX > 0 || el.sourceY > 0);
|
||||
|
||||
console.log('[splitAndInsertImages] Layout:', hasValidPositions ? 'relative' : 'grid', 'elements:', elements.length, 'scale:', scale);
|
||||
|
||||
if (hasValidPositions && !sourceRect) {
|
||||
// 计算所有子图的边界框
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
for (const el of elements) {
|
||||
const right = el.sourceX + el.width;
|
||||
const bottom = el.sourceY + el.height;
|
||||
if (right > maxRight) maxRight = right;
|
||||
if (bottom > maxBottom) maxBottom = bottom;
|
||||
}
|
||||
|
||||
console.log('[splitAndInsertImages] Bounding box:', { maxRight, maxBottom });
|
||||
|
||||
// 目标显示尺寸(灵感图的理想宽度)
|
||||
const targetWidth = 800;
|
||||
const targetHeight = 600;
|
||||
|
||||
// 计算缩放比例,保持宽高比
|
||||
if (maxRight > 0 && maxBottom > 0) {
|
||||
scale = Math.min(
|
||||
targetWidth / maxRight,
|
||||
targetHeight / maxBottom,
|
||||
1 // 不放大,只缩小
|
||||
);
|
||||
console.log('[splitAndInsertImages] Calculated scale for inspiration board:', scale);
|
||||
}
|
||||
}
|
||||
|
||||
let firstInsertPoint: Point | undefined;
|
||||
let firstElementSize: { width: number; height: number } | undefined;
|
||||
|
||||
// 动态导入 unifiedCacheService
|
||||
const { unifiedCacheService } = await import('../services/unified-cache-service');
|
||||
|
||||
// 如果没有有效位置信息,计算网格布局
|
||||
const gridLayout: { x: number; y: number; width: number; height: number }[] = [];
|
||||
if (!hasValidPositions && elements.length > 1) {
|
||||
// 计算网格布局:根据图片数量自动确定行列数
|
||||
const count = elements.length;
|
||||
const cols = Math.ceil(Math.sqrt(count));
|
||||
const rows = Math.ceil(count / cols);
|
||||
|
||||
// 计算每个图片的目标尺寸(取最大尺寸的平均值)
|
||||
const avgWidth = elements.reduce((sum, el) => sum + el.width, 0) / elements.length;
|
||||
const avgHeight = elements.reduce((sum, el) => sum + el.height, 0) / elements.length;
|
||||
|
||||
// 缩放到合适的显示尺寸(最大 400px)
|
||||
const maxDisplaySize = 400;
|
||||
const targetScale = Math.min(maxDisplaySize / Math.max(avgWidth, avgHeight), 1);
|
||||
const cellWidth = avgWidth * targetScale;
|
||||
const cellHeight = avgHeight * targetScale;
|
||||
const gap = 20; // 图片间距
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const row = Math.floor(i / cols);
|
||||
const col = i % cols;
|
||||
|
||||
// 计算单个图片的缩放比例,使其适应 cell 尺寸
|
||||
const element = elements[i];
|
||||
const elementScale = Math.min(
|
||||
cellWidth / element.width,
|
||||
cellHeight / element.height,
|
||||
1 // 不放大,只缩小
|
||||
);
|
||||
|
||||
gridLayout.push({
|
||||
x: baseX + col * (cellWidth + gap),
|
||||
y: baseY + row * (cellHeight + gap),
|
||||
width: element.width * elementScale,
|
||||
height: element.height * elementScale,
|
||||
});
|
||||
}
|
||||
|
||||
// console.log('[splitAndInsertImages] Grid layout:', { cols, rows, cellWidth, cellHeight, gap });
|
||||
}
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
|
||||
// 计算子图片在原图中的相对位置(缩放后)
|
||||
const relativeX = element.sourceX * scale;
|
||||
const relativeY = element.sourceY * scale;
|
||||
let insertX: number;
|
||||
let insertY: number;
|
||||
let scaledWidth: number;
|
||||
let scaledHeight: number;
|
||||
|
||||
// 计算最终插入位置
|
||||
const insertX = baseX + relativeX;
|
||||
const insertY = baseY + relativeY;
|
||||
|
||||
// 计算缩放后的尺寸
|
||||
const scaledWidth = element.width * scale;
|
||||
const scaledHeight = element.height * scale;
|
||||
if (hasValidPositions) {
|
||||
// 有位置信息:使用相对位置布局(宫格图等)
|
||||
const relativeX = element.sourceX * scale;
|
||||
const relativeY = element.sourceY * scale;
|
||||
insertX = baseX + relativeX;
|
||||
insertY = baseY + relativeY;
|
||||
scaledWidth = element.width * scale;
|
||||
scaledHeight = element.height * scale;
|
||||
} else if (gridLayout.length > 0) {
|
||||
// 无位置信息:使用网格布局(灵感图等)
|
||||
const layout = gridLayout[i];
|
||||
insertX = layout?.x ?? baseX;
|
||||
insertY = layout?.y ?? baseY;
|
||||
scaledWidth = layout?.width ?? element.width * scale;
|
||||
scaledHeight = layout?.height ?? element.height * scale;
|
||||
} else {
|
||||
// 单个图片,直接在基准位置插入
|
||||
insertX = baseX;
|
||||
insertY = baseY;
|
||||
scaledWidth = element.width * scale;
|
||||
scaledHeight = element.height * scale;
|
||||
}
|
||||
|
||||
// 记录第一个插入点用于滚动
|
||||
if (!firstInsertPoint) {
|
||||
|
||||
@@ -925,18 +925,21 @@ export async function detectPhotoWallRegions(
|
||||
/**
|
||||
* 递归拆分单个区域
|
||||
* 如果区域内还能检测到多个子区域,继续递归拆分
|
||||
* @param imageUrl 当前区域的图片 URL
|
||||
* @param depth 当前递归深度
|
||||
* @param maxDepth 最大递归深度
|
||||
* @param offsetX 当前区域相对于原始图片的 X 偏移量
|
||||
* @param offsetY 当前区域相对于原始图片的 Y 偏移量
|
||||
*/
|
||||
async function splitRegionRecursively(
|
||||
imageUrl: string,
|
||||
depth: number = 0,
|
||||
maxDepth: number = 5
|
||||
depth = 0,
|
||||
maxDepth = 5,
|
||||
offsetX = 0,
|
||||
offsetY = 0
|
||||
): Promise<ImageElement[]> {
|
||||
// const indent = ' '.repeat(depth);
|
||||
// console.log(`${indent}[RecursiveSplit] Depth ${depth}, analyzing image...`);
|
||||
|
||||
// 防止无限递归
|
||||
if (depth >= maxDepth) {
|
||||
// console.log(`${indent}[RecursiveSplit] Max depth reached, returning as leaf`);
|
||||
const img = await loadImage(imageUrl);
|
||||
return [{
|
||||
id: `photo-wall-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
|
||||
@@ -944,6 +947,8 @@ async function splitRegionRecursively(
|
||||
originalIndex: 0,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
sourceX: offsetX,
|
||||
sourceY: offsetY,
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -952,7 +957,6 @@ async function splitRegionRecursively(
|
||||
|
||||
// 如果检测到 0 或 1 个区域,说明无法继续拆分,返回当前图片
|
||||
if (detection.count <= 1) {
|
||||
// console.log(`${indent}[RecursiveSplit] Found ${detection.count} region(s), this is a leaf image`);
|
||||
|
||||
// 去白边后返回
|
||||
const trimmedImageData = await removeWhiteBorder(imageUrl, { borderRatio: 0.3 });
|
||||
@@ -962,7 +966,6 @@ async function splitRegionRecursively(
|
||||
|
||||
// 过滤太小的图片
|
||||
if (trimmedWidth < 50 || trimmedHeight < 50) {
|
||||
// console.log(`${indent}[RecursiveSplit] Image too small (${trimmedWidth}x${trimmedHeight}), skipping`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -972,11 +975,11 @@ async function splitRegionRecursively(
|
||||
originalIndex: 0,
|
||||
width: trimmedWidth,
|
||||
height: trimmedHeight,
|
||||
sourceX: offsetX,
|
||||
sourceY: offsetY,
|
||||
}];
|
||||
}
|
||||
|
||||
// console.log(`${indent}[RecursiveSplit] Found ${detection.count} regions, splitting further...`);
|
||||
|
||||
// 检测到多个区域,提取每个区域并递归处理
|
||||
const img = await loadImage(imageUrl);
|
||||
const allElements: ImageElement[] = [];
|
||||
@@ -1005,8 +1008,14 @@ async function splitRegionRecursively(
|
||||
|
||||
const regionImageUrl = regionCanvas.toDataURL('image/jpeg', 0.92);
|
||||
|
||||
// 递归拆分这个区域
|
||||
const subElements = await splitRegionRecursively(regionImageUrl, depth + 1, maxDepth);
|
||||
// 递归拆分这个区域,累积位置偏移
|
||||
const subElements = await splitRegionRecursively(
|
||||
regionImageUrl,
|
||||
depth + 1,
|
||||
maxDepth,
|
||||
offsetX + region.x, // 累积 X 偏移
|
||||
offsetY + region.y // 累积 Y 偏移
|
||||
);
|
||||
allElements.push(...subElements);
|
||||
}
|
||||
|
||||
@@ -1018,8 +1027,6 @@ async function splitRegionRecursively(
|
||||
* 会递归拆分直到每个区域都无法再拆分为止
|
||||
*/
|
||||
export async function splitPhotoWall(imageUrl: string): Promise<ImageElement[]> {
|
||||
// console.log('[PhotoWallSplitter] Starting recursive split...');
|
||||
|
||||
const elements = await splitRegionRecursively(imageUrl, 0, 5);
|
||||
|
||||
// 重新分配 ID 和 index
|
||||
@@ -1031,7 +1038,6 @@ export async function splitPhotoWall(imageUrl: string): Promise<ImageElement[]>
|
||||
|
||||
// 按面积降序排序(大图在前)
|
||||
finalElements.sort((a, b) => b.width * b.height - a.width * a.height);
|
||||
|
||||
// console.log(`[PhotoWallSplitter] Recursive split complete: ${finalElements.length} images`);
|
||||
|
||||
return finalElements;
|
||||
}
|
||||
Reference in new issue
Block a user