437 lines
13 KiB
TypeScript
437 lines
13 KiB
TypeScript
import { useCallback } from 'react'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
imageMatting,
|
|
imageOCR,
|
|
recognizeImage,
|
|
updateImageOCRText,
|
|
segmentLayer,
|
|
getSegmentLayerResult,
|
|
} from '../service/api'
|
|
import type {
|
|
ImageOCRResponse,
|
|
ImageOCRTextUpdateItem,
|
|
RecognizedImageElement,
|
|
} from '../service/type'
|
|
import {
|
|
type ImageElement,
|
|
type PlaceholderElement,
|
|
type SceneElement,
|
|
type ArtboardElement,
|
|
useDominoStoreInstance,
|
|
} from '../components/canvas'
|
|
import {
|
|
MAX_IMAGE_SIZE_BYTES,
|
|
MATTING_MIN_DIM,
|
|
MATTING_MAX_DIM,
|
|
NEED_COMPRESS_MAX_SIZE,
|
|
NEED_COMPRESS_MIN_SIZE,
|
|
} from '../consts'
|
|
import {
|
|
getImageFileSize,
|
|
getImageDimension,
|
|
getImageUrl,
|
|
isValidImageFormat,
|
|
isValidImageSize,
|
|
manualPersistence,
|
|
} from '../utils/helper'
|
|
|
|
export function useImageEditActions(
|
|
taskId: string,
|
|
addElementToFlow: (elementTemplate: SceneElement) => SceneElement,
|
|
onPartialRedraw?: (
|
|
element: ImageElement,
|
|
recognizedElements: readonly RecognizedImageElement[],
|
|
) => void,
|
|
) {
|
|
const store = useDominoStoreInstance()
|
|
|
|
const handleMatting = useCallback(
|
|
async (element: ImageElement) => {
|
|
const { originalWidth: w, originalHeight: h } = element
|
|
|
|
// 1. Dimension check
|
|
if (w < MATTING_MIN_DIM || h < MATTING_MIN_DIM) {
|
|
toast.error('图片尺寸不能小于 32x32px')
|
|
return
|
|
}
|
|
if (w > MATTING_MAX_DIM || h > MATTING_MAX_DIM) {
|
|
toast.error('图片尺寸不能超过 4000x4000px')
|
|
return
|
|
}
|
|
|
|
// 2. Size check
|
|
const { updateElement } = store.getState()
|
|
const fileSize = await getImageFileSize(element)
|
|
if (!element.data?.size && fileSize > 0) {
|
|
updateElement(element.id, { data: { ...element.data, size: fileSize } })
|
|
}
|
|
if (fileSize > MAX_IMAGE_SIZE_BYTES) {
|
|
toast.error('图片大小不能超过 20MB')
|
|
return
|
|
}
|
|
|
|
const placeholderId = uuidv4()
|
|
const { removePlaceholder, updateElementUIState, addElement } =
|
|
store.getState()
|
|
|
|
// Immediately insert placeholder element for instant feedback
|
|
const placeholder: PlaceholderElement = {
|
|
id: placeholderId,
|
|
type: 'placeholder',
|
|
x: 0,
|
|
y: 0,
|
|
width: element.width,
|
|
height: element.height,
|
|
rotation: element.rotation || 0,
|
|
originalWidth: element.width,
|
|
originalHeight: element.height,
|
|
label: '生成中',
|
|
}
|
|
const addedPlaceholder = addElementToFlow(
|
|
placeholder,
|
|
) as PlaceholderElement
|
|
updateElementUIState(placeholderId, { status: 'pending' })
|
|
|
|
try {
|
|
const res = await imageMatting({
|
|
image_url: element.data?.path || element.id,
|
|
task_id: taskId,
|
|
compress: fileSize > NEED_COMPRESS_MIN_SIZE,
|
|
})
|
|
|
|
try {
|
|
// Preload image
|
|
const url = await getImageUrl(taskId, res.path)
|
|
const { width, height } = await getImageDimension(url)
|
|
|
|
// Replace placeholder with matting result
|
|
removePlaceholder(placeholderId)
|
|
const newImage: ImageElement = {
|
|
...addedPlaceholder,
|
|
id: res.path,
|
|
type: 'image',
|
|
src: url,
|
|
width,
|
|
height,
|
|
fileName: res.path.split('/').pop() || 'image.jpg',
|
|
originalWidth: width,
|
|
originalHeight: height,
|
|
data: {
|
|
path: res.path,
|
|
},
|
|
}
|
|
addElement(newImage)
|
|
updateElementUIState(res.path, { status: 'idle' })
|
|
|
|
// Support background task persistence
|
|
manualPersistence(taskId, store)
|
|
} catch (e) {
|
|
console.warn('Preload failed', e)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
removePlaceholder(placeholderId)
|
|
}
|
|
},
|
|
[taskId, addElementToFlow, store],
|
|
)
|
|
|
|
const handlePartialRedraw = useCallback(
|
|
async (element: ImageElement) => {
|
|
if (!isValidImageFormat(element.fileName)) {
|
|
toast.error('不合法的图片格式')
|
|
return
|
|
}
|
|
if (!isValidImageSize(element.originalWidth, element.originalHeight)) {
|
|
toast.error('图片尺寸不能超过 2160x3840px 或 3840x2160px')
|
|
return
|
|
}
|
|
|
|
const { updateElement, updateElementUIState } = store.getState()
|
|
const fileSize = await getImageFileSize(element)
|
|
if (!element.data?.size && fileSize > 0) {
|
|
updateElement(element.id, { data: { ...element.data, size: fileSize } })
|
|
}
|
|
if (fileSize >= MAX_IMAGE_SIZE_BYTES) {
|
|
toast.error('图片大小不能超过 20MB')
|
|
return
|
|
}
|
|
|
|
updateElementUIState(element.id, {
|
|
status: 'pending',
|
|
statusText: '识图中...',
|
|
})
|
|
|
|
try {
|
|
const res = await recognizeImage({
|
|
image_url: element.data?.path || element.id,
|
|
task_id: taskId,
|
|
width: element.originalWidth,
|
|
height: element.originalHeight,
|
|
})
|
|
onPartialRedraw?.(element, res || [])
|
|
} catch (err) {
|
|
console.error('Failed to recognize image:', err)
|
|
} finally {
|
|
updateElementUIState(element.id, {
|
|
status: 'idle',
|
|
})
|
|
}
|
|
},
|
|
[taskId, onPartialRedraw, store],
|
|
)
|
|
|
|
const handleEditText = useCallback(
|
|
async (
|
|
element: ImageElement,
|
|
onOCRSuccess: (data: ImageOCRResponse) => void,
|
|
) => {
|
|
if (!isValidImageFormat(element.fileName)) {
|
|
toast.error('不合法的图片格式')
|
|
return
|
|
}
|
|
if (!isValidImageSize(element.originalWidth, element.originalHeight)) {
|
|
toast.error('图片尺寸不能超过 2160x3840px 或 3840x2160px')
|
|
return
|
|
}
|
|
|
|
const { updateElement, updateElementUIState } = store.getState()
|
|
const fileSize = await getImageFileSize(element)
|
|
if (!element.data?.size && fileSize > 0) {
|
|
updateElement(element.id, { data: { ...element.data, size: fileSize } })
|
|
}
|
|
if (fileSize >= MAX_IMAGE_SIZE_BYTES) {
|
|
toast.error('图片大小不能超过 20MB')
|
|
return
|
|
}
|
|
|
|
updateElementUIState(element.id, {
|
|
status: 'pending',
|
|
statusText: '正在提取文字',
|
|
})
|
|
|
|
const errorText = '没有提取到文字'
|
|
|
|
try {
|
|
const data = await imageOCR({
|
|
image_url: element.data?.path || element.id,
|
|
task_id: taskId,
|
|
})
|
|
if (data.length > 0) {
|
|
onOCRSuccess(data)
|
|
} else {
|
|
toast.error(errorText)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error(errorText)
|
|
} finally {
|
|
updateElementUIState(element.id, { status: 'idle' })
|
|
}
|
|
},
|
|
[taskId, store],
|
|
)
|
|
|
|
const handleConfirmEditText = useCallback(
|
|
async (element: ImageElement, updatedList: ImageOCRTextUpdateItem[]) => {
|
|
const placeholderId = uuidv4()
|
|
const { removePlaceholder, updateElementUIState, addElement } =
|
|
store.getState()
|
|
|
|
// Insert new placeholder element into the flow
|
|
const placeholder: PlaceholderElement = {
|
|
id: placeholderId,
|
|
type: 'placeholder',
|
|
x: 0,
|
|
y: 0,
|
|
width: element.width,
|
|
height: element.height,
|
|
rotation: element.rotation || 0,
|
|
originalWidth: element.width,
|
|
originalHeight: element.height,
|
|
label: '处理中...',
|
|
}
|
|
const addedPlaceholder = addElementToFlow(
|
|
placeholder,
|
|
) as PlaceholderElement
|
|
updateElementUIState(placeholderId, { status: 'pending' })
|
|
|
|
try {
|
|
const res = await updateImageOCRText({
|
|
image_url: element.data?.path || element.id,
|
|
texts: updatedList,
|
|
task_id: taskId,
|
|
compress: (element.data?.size ?? 0) > NEED_COMPRESS_MAX_SIZE,
|
|
})
|
|
|
|
// Preload next image
|
|
try {
|
|
const url = await getImageUrl(taskId, res.path)
|
|
const { width, height } = await getImageDimension(url)
|
|
|
|
// Replace placeholder with result
|
|
removePlaceholder(placeholderId)
|
|
const newImage: ImageElement = {
|
|
...addedPlaceholder,
|
|
id: res.path,
|
|
type: 'image',
|
|
src: url,
|
|
width,
|
|
height,
|
|
fileName: res.path.split('/').pop() || 'image.jpg',
|
|
originalWidth: width,
|
|
originalHeight: height,
|
|
data: {
|
|
path: res.path,
|
|
},
|
|
}
|
|
addElement(newImage)
|
|
updateElementUIState(res.path, { status: 'idle' })
|
|
|
|
// Support background task persistence
|
|
manualPersistence(taskId, store)
|
|
} catch (e) {
|
|
console.warn('Preload failed', e)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
removePlaceholder(placeholderId)
|
|
toast.error('修改失败')
|
|
}
|
|
},
|
|
[taskId, addElementToFlow, store],
|
|
)
|
|
|
|
const handleEditElements = useCallback(
|
|
async (element: ImageElement) => {
|
|
const { updateElementUIState, addElement, setSelectedIds } =
|
|
store.getState()
|
|
|
|
updateElementUIState(element.id, {
|
|
status: 'pending',
|
|
statusText: '正在分层...',
|
|
})
|
|
|
|
try {
|
|
const taskRecordId = await segmentLayer({
|
|
image_url: element.data?.path || element.id,
|
|
task_id: taskId,
|
|
width: element.originalWidth,
|
|
height: element.originalHeight,
|
|
})
|
|
|
|
// Polling loop
|
|
let task = await getSegmentLayerResult(taskRecordId)
|
|
while (task.status === 'PROCESSING') {
|
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
task = await getSegmentLayerResult(taskRecordId)
|
|
}
|
|
|
|
if (
|
|
task.status === 'TIMEOUT' ||
|
|
task.status === 'FAILED' ||
|
|
!task.layers
|
|
) {
|
|
throw new Error('分层任务执行失败')
|
|
}
|
|
|
|
const layersData = task.layers
|
|
|
|
// 1. Prepare all layers (Sorted by layer_order from server)
|
|
const allLayersData = layersData.map(layer => ({
|
|
image: layer.image,
|
|
line_rect: {
|
|
x: layer.x,
|
|
y: layer.y,
|
|
width: layer.width,
|
|
height: layer.height,
|
|
},
|
|
line_text: layer.desc,
|
|
hideMetadata: layer.desc === 'background',
|
|
}))
|
|
|
|
// 2. Preload all layers and prepare IDs
|
|
const layers = await Promise.all(
|
|
allLayersData.map(async item => {
|
|
const itemUrl = await getImageUrl(taskId, item.image.path)
|
|
const childId = uuidv4()
|
|
return { item, itemUrl, childId }
|
|
}),
|
|
)
|
|
|
|
// 3. Create Artboard Template
|
|
const artboardId = uuidv4()
|
|
const artboardTemplate: ArtboardElement = {
|
|
id: artboardId,
|
|
type: 'artboard',
|
|
name: `画板 - ${element.fileName}`,
|
|
x: 0,
|
|
y: 0,
|
|
width: element.width,
|
|
height: element.height,
|
|
rotation: element.rotation || 0,
|
|
originalWidth: element.originalWidth,
|
|
originalHeight: element.originalHeight,
|
|
background: '#FFFFFF',
|
|
childrenIds: layers.map(l => l.childId),
|
|
}
|
|
|
|
// Add artboard to flow
|
|
const addedArtboard = addElementToFlow(
|
|
artboardTemplate,
|
|
) as ArtboardElement
|
|
|
|
// 4. Add all child elements to store
|
|
for (const layer of layers) {
|
|
const { item, itemUrl, childId } = layer
|
|
const childImage: ImageElement = {
|
|
id: childId,
|
|
type: 'image',
|
|
x: item.line_rect.x,
|
|
y: item.line_rect.y,
|
|
width: item.line_rect.width,
|
|
height: item.line_rect.height,
|
|
rotation: 0,
|
|
originalWidth: item.line_rect.width,
|
|
originalHeight: item.line_rect.height,
|
|
src: itemUrl,
|
|
fileName: item.image.path.split('/').pop() || 'layer.png',
|
|
parentId: artboardId,
|
|
selectable: true,
|
|
hideMetadata: item.hideMetadata,
|
|
data: {
|
|
path: item.image.path,
|
|
text: item.line_text,
|
|
},
|
|
}
|
|
addElement(childImage)
|
|
}
|
|
|
|
setSelectedIds([addedArtboard.id])
|
|
|
|
// Support background task persistence
|
|
manualPersistence(taskId, store)
|
|
} catch (err) {
|
|
console.error('Failed to segment text layer:', err)
|
|
toast.error('修改失败')
|
|
} finally {
|
|
updateElementUIState(element.id, {
|
|
status: 'idle',
|
|
statusText: undefined,
|
|
})
|
|
}
|
|
},
|
|
[taskId, store, addElementToFlow],
|
|
)
|
|
|
|
return {
|
|
handleMatting,
|
|
handlePartialRedraw,
|
|
handleEditText,
|
|
handleConfirmEditText,
|
|
handleEditElements,
|
|
}
|
|
}
|