docs: 添加共享模块设计规则

- 清理 CLAUDE.md 中重复的规则 8-10
- 添加规则 11:共享模块设计(*-core.ts 命名规范)
- 在 CODING_RULES.md 添加详细示例(TrimMode 配置模式)
This commit is contained in:
liquidliang committed 2026-01-23 15:42:53 +08:00
1 parent 718c4fbb53
commit 53d0900a3c
13 files changed
+560 -222

No files matched your search

+1
View File
@@ -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,
+48
View File
@@ -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;
}
/**
+241 -139
View File
@@ -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;
}