diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx index 361e08c7e..ff16523ef 100644 --- a/components/scene-fusion-view.tsx +++ b/components/scene-fusion-view.tsx @@ -40,6 +40,7 @@ import { SelectTrigger, } from "@/components/ui/select"; import { createCanvasPsdTemplateLaunchToken, saveCanvasPsdTemplateLaunchPayload } from "@/lib/canvas/psd-template-launch"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { requestLongTaskResult } from "@/lib/client/long-task"; import { SCENE_FUSION_ASPECT_RATIO_OPTIONS, @@ -171,6 +172,23 @@ function createPersistableSnapshot(snapshot: SceneFusionPersistedState): SceneFu }; } +function getUploadedImageUrl(payload: unknown) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return ""; + } + const raw = payload as Record; + return String( + raw.url || + raw.originalUrl || + raw.original_url || + raw.previewUrl || + raw.preview_url || + raw.thumbnailUrl || + raw.thumbnail_url || + "" + ).trim(); +} + const randomBackgroundStyles = [ "现代简约客厅", "北欧卧室", @@ -447,6 +465,52 @@ export function SceneFusionView({ updateProductPreview(null); }; + const uploadImageFile = async (file: File) => { + const formData = new FormData(); + formData.append("file", file, file.name || `scene-fusion-${Date.now()}.png`); + const response = await fetchUploadWithRetry(formData); + const payload = await response.json().catch(() => ({})); + const imageUrl = getUploadedImageUrl(payload); + if (!response.ok || !imageUrl) { + throw new Error( + String( + payload?.error || + payload?.details || + payload?.message || + `${file.name || "图片"}上传失败` + ) + ); + } + return imageUrl; + }; + + const validateImageFiles = (files: File[]) => { + const validFiles: File[] = []; + for (const file of files) { + if (!file.type.startsWith("image/")) { + toast.error(`${file.name || "文件"}不是图片文件`); + continue; + } + if (file.size > 50 * 1024 * 1024) { + toast.error(`图片 ${file.name} 大小不能超过 50MB`); + continue; + } + validFiles.push(file); + } + return validFiles; + }; + + const uploadImageFiles = async (files: File[]) => { + const uploadedItems = await Promise.all( + files.map(async (file, index) => ({ + file, + index, + imageUrl: await uploadImageFile(file), + })) + ); + return uploadedItems.sort((left, right) => left.index - right.index); + }; + useEffect(() => { if (initialProductImage) { setProductImage(initialProductImage); @@ -479,21 +543,25 @@ export function SceneFusionView({ if (typeof parsed.imageSize === "string") { setImageSize(parsed.imageSize); } - if (typeof parsed.productImage === "string" && parsed.productImage.trim()) { - setProductImage(parsed.productImage); - updateProductPreview(parsed.productImage); + const restoredProductImage = toPersistableImageValue(parsed.productImage); + if (restoredProductImage) { + setProductImage(restoredProductImage); + updateProductPreview(restoredProductImage); } if (typeof parsed.productName === "string") { setProductName(parsed.productName); } if (Array.isArray(parsed.sceneItems)) { - setSceneItems(parsed.sceneItems); + setSceneItems(toPersistableItems(parsed.sceneItems)); } if (Array.isArray(parsed.multiProductItems)) { - setMultiProductItems(parsed.multiProductItems); + setMultiProductItems(toPersistableItems(parsed.multiProductItems)); } - if (typeof parsed.referenceSceneImage === "string") { - setReferenceSceneImage(parsed.referenceSceneImage || null); + const restoredReferenceSceneImage = toPersistableImageValue( + parsed.referenceSceneImage + ); + if (restoredReferenceSceneImage) { + setReferenceSceneImage(restoredReferenceSceneImage); } if (typeof parsed.referenceTargetName === "string") { setReferenceTargetName(parsed.referenceTargetName); @@ -502,7 +570,7 @@ export function SceneFusionView({ setReferenceCustomPrompt(parsed.referenceCustomPrompt); } if (Array.isArray(parsed.randomItems)) { - setRandomItems(parsed.randomItems); + setRandomItems(toPersistableItems(parsed.randomItems)); } if (typeof parsed.randomCount === "number" && Number.isFinite(parsed.randomCount)) { setRandomCount(Math.max(1, Math.min(MAX_BATCH_ITEMS, parsed.randomCount))); @@ -511,7 +579,7 @@ export function SceneFusionView({ setRandomPrompt(parsed.randomPrompt); } if (Array.isArray(parsed.batchItems)) { - setBatchItems(parsed.batchItems); + setBatchItems(toPersistableItems(parsed.batchItems)); } if (Array.isArray(parsed.selectedResultIds)) { setSelectedResultIds(parsed.selectedResultIds.map((value) => String(value || "").trim()).filter(Boolean)); @@ -638,144 +706,149 @@ export function SceneFusionView({ selectedResultIds, ]); - const handleProductUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; + const handleProductUpload = async (e: React.ChangeEvent) => { + const input = e.currentTarget; + const file = input.files?.[0]; if (!file) return; - if (file.size > 50 * 1024 * 1024) { - toast.error("图片大小不能超过 50MB"); + const [validFile] = validateImageFiles([file]); + if (!validFile) { + input.value = ""; return; } setProductImage(null); - const previewUrl = URL.createObjectURL(file); + const previewUrl = URL.createObjectURL(validFile); updateProductPreview(previewUrl, true); - const reader = new FileReader(); - reader.onload = (event) => { - const result = event.target?.result; - if (typeof result === "string") { - setProductImage(result); - } else { - toast.error("图片读取失败,请重试"); - } - }; - reader.onerror = () => toast.error("图片读取失败,请重试"); - reader.readAsDataURL(file); - e.target.value = ""; + try { + const imageUrl = await uploadImageFile(validFile); + setProductImage(imageUrl); + updateProductPreview(imageUrl); + toast.success("产品图上传成功"); + } catch (error) { + updateProductPreview(null); + toast.error(error instanceof Error ? error.message : "产品图上传失败"); + } finally { + input.value = ""; + } }; - const handleSceneUpload = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - if (files.length === 0) return; + const handleSceneUpload = async (e: React.ChangeEvent) => { + const input = e.currentTarget; + const files = validateImageFiles(Array.from(input.files || [])); + if (files.length === 0) { + input.value = ""; + return; + } if (sceneItems.length + files.length > maxScenes) { toast.error(`单次最多上传 ${maxScenes} 张参考图`); + input.value = ""; return; } - files.forEach((file) => { - if (file.size > 50 * 1024 * 1024) { - toast.error(`图片 ${file.name} 大小不能超过 50MB`); - return; - } - const reader = new FileReader(); - reader.onload = (event) => { - const image = event.target?.result as string; - setSceneItems((prev) => [ - ...prev, - { - id: Math.random().toString(36).slice(2, 10), - image, - targetName: "", - customPrompt: "", - status: "pending", - }, - ]); - }; - reader.readAsDataURL(file); - }); - - e.target.value = ""; + try { + const uploadedItems = await uploadImageFiles(files); + setSceneItems((prev) => [ + ...prev, + ...uploadedItems.map((item) => ({ + id: Math.random().toString(36).slice(2, 10), + image: item.imageUrl, + targetName: "", + customPrompt: "", + status: "pending" as ItemStatus, + })), + ]); + toast.success(`已上传 ${uploadedItems.length} 张参考图`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "参考图上传失败"); + } finally { + input.value = ""; + } }; - const handleMultiProductUpload = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - if (files.length === 0) return; + const handleMultiProductUpload = async (e: React.ChangeEvent) => { + const input = e.currentTarget; + const files = validateImageFiles(Array.from(input.files || [])); + if (files.length === 0) { + input.value = ""; + return; + } if (multiProductItems.length + files.length > MAX_BATCH_ITEMS) { toast.error(`单次最多上传 ${MAX_BATCH_ITEMS} 张产品图`); + input.value = ""; return; } - files.forEach((file) => { - if (file.size > 50 * 1024 * 1024) { - toast.error(`图片 ${file.name} 大小不能超过 50MB`); - return; - } - const reader = new FileReader(); - reader.onload = (event) => { - const image = event.target?.result as string; - setMultiProductItems((prev) => [ - ...prev, - { - id: Math.random().toString(36).slice(2, 10), - image, - status: "pending", - }, - ]); - }; - reader.readAsDataURL(file); - }); - - e.target.value = ""; + try { + const uploadedItems = await uploadImageFiles(files); + setMultiProductItems((prev) => [ + ...prev, + ...uploadedItems.map((item) => ({ + id: Math.random().toString(36).slice(2, 10), + image: item.imageUrl, + status: "pending" as ItemStatus, + })), + ]); + toast.success(`已上传 ${uploadedItems.length} 张产品图`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "产品图上传失败"); + } finally { + input.value = ""; + } }; - const handleReferenceSceneUpload = ( + const handleReferenceSceneUpload = async ( e: React.ChangeEvent ) => { - const file = e.target.files?.[0]; + const input = e.currentTarget; + const file = input.files?.[0]; if (!file) return; - if (file.size > 50 * 1024 * 1024) { - toast.error("图片大小不能超过 50MB"); + const [validFile] = validateImageFiles([file]); + if (!validFile) { + input.value = ""; return; } - const reader = new FileReader(); - reader.onload = (event) => { - const image = event.target?.result as string; - setReferenceSceneImage(image); - }; - reader.readAsDataURL(file); - e.target.value = ""; + try { + const imageUrl = await uploadImageFile(validFile); + setReferenceSceneImage(imageUrl); + toast.success("参考图上传成功"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "参考图上传失败"); + } finally { + input.value = ""; + } }; - const handleBatchUpload = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - if (files.length === 0) return; + const handleBatchUpload = async (e: React.ChangeEvent) => { + const input = e.currentTarget; + const files = validateImageFiles(Array.from(input.files || [])); + if (files.length === 0) { + input.value = ""; + return; + } if (batchItems.length + files.length > MAX_BATCH_ITEMS) { toast.error(`单次最多上传 ${MAX_BATCH_ITEMS} 张图片`); + input.value = ""; return; } - files.forEach((file) => { - if (file.size > 50 * 1024 * 1024) { - toast.error(`图片 ${file.name} 大小不能超过 50MB`); - return; - } - const reader = new FileReader(); - reader.onload = (event) => { - const image = event.target?.result as string; - setBatchItems((prev) => [ - ...prev, - { - id: Math.random().toString(36).slice(2, 10), - image, - status: "pending", - }, - ]); - }; - reader.readAsDataURL(file); - }); - - e.target.value = ""; + try { + const uploadedItems = await uploadImageFiles(files); + setBatchItems((prev) => [ + ...prev, + ...uploadedItems.map((item) => ({ + id: Math.random().toString(36).slice(2, 10), + image: item.imageUrl, + status: "pending" as ItemStatus, + })), + ]); + toast.success(`已上传 ${uploadedItems.length} 张图片`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "图片上传失败"); + } finally { + input.value = ""; + } }; const updateSceneItem = (id: string, patch: Partial) => { @@ -1468,6 +1541,52 @@ export function SceneFusionView({ } }; + const retryBatchImageItem = async (item: ProcessItem) => { + if (item.image.startsWith("data:")) { + toast.error("这张图仍是旧版本地数据,请重新上传后再处理"); + return; + } + + setIsProcessing(true); + setBatchItems((prev) => + prev.map((batchItem) => + batchItem.id === item.id + ? { ...batchItem, status: "processing", error: undefined } + : batchItem + ) + ); + + try { + const result = await processBatchImageItem(item); + setBatchItems((prev) => + prev.map((batchItem) => + batchItem.id === item.id + ? { + ...batchItem, + status: "completed", + result: result.result, + originalUrl: result.originalUrl, + error: undefined, + } + : batchItem + ) + ); + toast.success("已重新处理 1 张图片"); + } catch (error) { + const message = error instanceof Error ? error.message : "处理失败"; + setBatchItems((prev) => + prev.map((batchItem) => + batchItem.id === item.id + ? { ...batchItem, status: "failed", error: message } + : batchItem + ) + ); + toast.error(message); + } finally { + setIsProcessing(false); + } + }; + const startProcessing = async () => { if (mode === "scene-fission") { if (!productImage) { @@ -1570,14 +1689,7 @@ export function SceneFusionView({ return; } - setBatchItems((prev) => - prev.map((batchItem) => - batchItem.id === item.id - ? { ...batchItem, status: "pending", error: undefined } - : batchItem - ) - ); - await runBatchImageMode(); + await retryBatchImageItem(item); }; const downloadImage = async (url: string, filename: string) => {