From 53d0900a3caeddd3ba2915f3973822f5f2dd310e Mon Sep 17 00:00:00 2001 From: liquidliang Date: Fri, 23 Jan 2026 15:39:08 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E8=AE=BE=E8=AE=A1=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 清理 CLAUDE.md 中重复的规则 8-10 - 添加规则 11:共享模块设计(*-core.ts 命名规范) - 在 CODING_RULES.md 添加详细示例(TrimMode 配置模式) --- CLAUDE.md | 1 + .../src/sw/task-queue/workflow-executor.ts | 13 +- docs/CODING_RULES.md | 48 +++ .../components/ai-input-bar/AIInputBar.tsx | 87 +++- .../ai-input-bar/PromptHistoryPopover.tsx | 52 ++- .../chat-drawer/EnhancedChatInput.tsx | 4 +- .../src/hooks/useAutoInsertToCanvas.ts | 71 ++++ .../drawnix/src/hooks/usePromptHistory.ts | 6 +- .../src/hooks/useWorkflowSubmission.ts | 63 ++- .../src/services/prompt-storage-service.ts | 15 +- .../drawnix/src/types/photo-wall.types.ts | 4 + packages/drawnix/src/utils/image-splitter.ts | 380 +++++++++++------- .../drawnix/src/utils/photo-wall-splitter.ts | 38 +- 13 files changed, 560 insertions(+), 222 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9c8f6d01..3479bc61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,7 @@ Service Worker (后台执行) 9. **结构化数据**:复杂消息展示应优先使用 `aiContext` 等结构化数据而非字符串解析 10. **图标验证**:使用 `tdesign-icons-react` 前需验证导出名称是否存在(如 `ServiceIcon`) 11. **外部 API 调用频率**:低频刷新的外部接口(如健康检查、状态查询)必须使用单例控制调用频率 +12. **共享模块设计**:相似功能提取到 `*-core.ts` 核心模块,通过配置类型(如 `TrimMode`)区分行为 #### 外部 API 调用频率控制 diff --git a/apps/web/src/sw/task-queue/workflow-executor.ts b/apps/web/src/sw/task-queue/workflow-executor.ts index 608eac1f..8128649c 100644 --- a/apps/web/src/sw/task-queue/workflow-executor.ts +++ b/apps/web/src/sw/task-queue/workflow-executor.ts @@ -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 = { + '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, diff --git a/docs/CODING_RULES.md b/docs/CODING_RULES.md index e3ea6c46..e9eac430 100644 --- a/docs/CODING_RULES.md +++ b/docs/CODING_RULES.md @@ -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` 优化重渲染 diff --git a/packages/drawnix/src/components/ai-input-bar/AIInputBar.tsx b/packages/drawnix/src/components/ai-input-bar/AIInputBar.tsx index 42019455..8b38ae5d 100644 --- a/packages/drawnix/src/components/ai-input-bar/AIInputBar.tsx +++ b/packages/drawnix/src/components/ai-input-bar/AIInputBar.tsx @@ -605,16 +605,35 @@ export const AIInputBar: React.FC = 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 = 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 = 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 = 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 = 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); + } } } diff --git a/packages/drawnix/src/components/ai-input-bar/PromptHistoryPopover.tsx b/packages/drawnix/src/components/ai-input-bar/PromptHistoryPopover.tsx index 7c26e463..69cc1728 100644 --- a/packages/drawnix/src/components/ai-input-bar/PromptHistoryPopover.tsx +++ b/packages/drawnix/src/components/ai-input-bar/PromptHistoryPopover.tsx @@ -56,31 +56,41 @@ export const PromptHistoryPopover: React.FC = ({ })); }, [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]); // 清理定时器 diff --git a/packages/drawnix/src/components/chat-drawer/EnhancedChatInput.tsx b/packages/drawnix/src/components/chat-drawer/EnhancedChatInput.tsx index 785f1d6a..d8802d72 100644 --- a/packages/drawnix/src/components/chat-drawer/EnhancedChatInput.tsx +++ b/packages/drawnix/src/components/chat-drawer/EnhancedChatInput.tsx @@ -97,9 +97,9 @@ export const EnhancedChatInput = forwardRef { + 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 = {}): 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); } diff --git a/packages/drawnix/src/hooks/usePromptHistory.ts b/packages/drawnix/src/hooks/usePromptHistory.ts index c249b9eb..741f1fe3 100644 --- a/packages/drawnix/src/hooks/usePromptHistory.ts +++ b/packages/drawnix/src/hooks/usePromptHistory.ts @@ -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]); diff --git a/packages/drawnix/src/hooks/useWorkflowSubmission.ts b/packages/drawnix/src/hooks/useWorkflowSubmission.ts index a51ac066..5f976313 100644 --- a/packages/drawnix/src/hooks/useWorkflowSubmission.ts +++ b/packages/drawnix/src/hooks/useWorkflowSubmission.ts @@ -192,7 +192,7 @@ export function useWorkflowSubmission( event: WorkflowEvent, legacyWorkflow: LegacyWorkflowDefinition, retryContext: WorkflowRetryContext - ) => void) | null>(null); + ) => Promise) | 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); diff --git a/packages/drawnix/src/services/prompt-storage-service.ts b/packages/drawnix/src/services/prompt-storage-service.ts index 6e3f3f09..1ab52675 100644 --- a/packages/drawnix/src/services/prompt-storage-service.ts +++ b/packages/drawnix/src/services/prompt-storage-service.ts @@ -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); diff --git a/packages/drawnix/src/types/photo-wall.types.ts b/packages/drawnix/src/types/photo-wall.types.ts index 0c223f72..f1ce67a4 100644 --- a/packages/drawnix/src/types/photo-wall.types.ts +++ b/packages/drawnix/src/types/photo-wall.types.ts @@ -80,6 +80,10 @@ export interface ImageElement { width: number; /** 原始高度 */ height: number; + /** 在原图中的 X 坐标(用于按原图位置布局) */ + sourceX?: number; + /** 在原图中的 Y 坐标(用于按原图位置布局) */ + sourceY?: number; } /** diff --git a/packages/drawnix/src/utils/image-splitter.ts b/packages/drawnix/src/utils/image-splitter.ts index 08ce206e..50a80419 100644 --- a/packages/drawnix/src/utils/image-splitter.ts +++ b/packages/drawnix/src/utils/image-splitter.ts @@ -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 { 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 { // 防止无限递归 @@ -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) { diff --git a/packages/drawnix/src/utils/photo-wall-splitter.ts b/packages/drawnix/src/utils/photo-wall-splitter.ts index 69264cec..2e5393fa 100644 --- a/packages/drawnix/src/utils/photo-wall-splitter.ts +++ b/packages/drawnix/src/utils/photo-wall-splitter.ts @@ -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 { - // 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 { - // 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 // 按面积降序排序(大图在前) finalElements.sort((a, b) => b.width * b.height - a.width * a.height); - - // console.log(`[PhotoWallSplitter] Recursive split complete: ${finalElements.length} images`); + return finalElements; }