diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 4d41b05b0..bdd4c331f 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -63,6 +63,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { type ChangeEvent, type ComponentType, + type PointerEvent as ReactPointerEvent, startTransition, useCallback, useDeferredValue, @@ -1132,6 +1133,9 @@ function normalizeImportedCanvasState(payload: unknown): { } const FLOATING_PANEL_WIDTH = 400; +const FLOATING_PANEL_MAX_WIDTH = 760; +const FLOATING_PANEL_MIN_CANVAS_WIDTH = 520; +const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1"; const FLOATING_PANEL_INSET = 16; const DOCKED_PANEL_INSET = 0; const FLOATING_UI_GAP = 16; @@ -1145,6 +1149,33 @@ const AGENT_RESULT_MODE_LABELS: Record = detail_replacement: "细节替换", sku_replacement: "SKU 替换", }; + +function getMaxFloatingPanelWidth(viewportWidth: number) { + if (viewportWidth <= 0) { + return FLOATING_PANEL_MAX_WIDTH; + } + + const viewportLimitedMax = Math.max( + FLOATING_PANEL_WIDTH, + viewportWidth - FLOATING_PANEL_MIN_CANVAS_WIDTH - DOCKED_PANEL_INSET + ); + + return Math.max( + FLOATING_PANEL_WIDTH, + Math.min(FLOATING_PANEL_MAX_WIDTH, viewportLimitedMax) + ); +} + +function clampFloatingPanelWidth(width: number, viewportWidth: number) { + if (!Number.isFinite(width)) { + return FLOATING_PANEL_WIDTH; + } + + return Math.min( + Math.max(width, FLOATING_PANEL_WIDTH), + getMaxFloatingPanelWidth(viewportWidth) + ); +} const VIDEO_CANVAS_PANEL_CLASS = "pointer-events-auto overflow-hidden rounded-[24px] border border-[#dbe2ee] bg-white text-[#16181d] shadow-[0_0_0_1px_rgba(219,226,238,0.42),0_24px_52px_rgba(22,24,29,0.08)] dark:border-zinc-800 dark:bg-zinc-950"; const VIDEO_CANVAS_PANEL_BADGE_CLASS = @@ -1932,6 +1963,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState(null); const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode); + const [floatingPanelWidth, setFloatingPanelWidth] = + useState(FLOATING_PANEL_WIDTH); + const [ + isFloatingPanelWidthStorageReady, + setIsFloatingPanelWidthStorageReady, + ] = useState(false); + const [isFloatingPanelResizing, setIsFloatingPanelResizing] = + useState(false); const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] = useState(false); const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] = @@ -2956,10 +2995,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const resolvedPanelWidth = viewportWidth > 0 ? Math.min( - FLOATING_PANEL_WIDTH, + floatingPanelWidth, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0) ) - : FLOATING_PANEL_WIDTH; + : floatingPanelWidth; const isFloatingSidebarVisible = !isVideoMode && isPanelOpen; const panelOcclusionRight = isFloatingSidebarVisible ? resolvedPanelWidth + DOCKED_PANEL_INSET @@ -2969,6 +3008,82 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { : 0; const compactControlsLeft = FLOATING_UI_GAP; + const handleFloatingPanelResizePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const handleElement = event.currentTarget; + const pointerId = event.pointerId; + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + + const updateWidthFromClientX = (clientX: number) => { + const currentViewportWidth = window.innerWidth; + const nextWidth = + currentViewportWidth - DOCKED_PANEL_INSET - clientX; + + setFloatingPanelWidth( + clampFloatingPanelWidth(nextWidth, currentViewportWidth) + ); + }; + + const cleanup = () => { + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerEnd); + document.removeEventListener("pointercancel", handlePointerEnd); + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + setIsFloatingPanelResizing(false); + + try { + if (handleElement.hasPointerCapture(pointerId)) { + handleElement.releasePointerCapture(pointerId); + } + } catch { + // Pointer capture can already be released by the browser. + } + }; + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) { + return; + } + + moveEvent.preventDefault(); + updateWidthFromClientX(moveEvent.clientX); + }; + + const handlePointerEnd = (endEvent: PointerEvent) => { + if (endEvent.pointerId !== pointerId) { + return; + } + + cleanup(); + }; + + try { + handleElement.setPointerCapture(pointerId); + } catch { + // Some browsers may reject capture on synthetic pointer events. + } + + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + setIsFloatingPanelResizing(true); + updateWidthFromClientX(event.clientX); + + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerEnd); + document.addEventListener("pointercancel", handlePointerEnd); + }, + [] + ); + const updateCanvasSearchParams = useCallback( (updates: Record) => { const nextParams = new URLSearchParams(searchParams.toString()); @@ -3343,6 +3458,49 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { return () => window.removeEventListener("resize", updateViewportWidth); }, []); + useEffect(() => { + if (typeof window === "undefined") { + setIsFloatingPanelWidthStorageReady(true); + return; + } + + const storedWidth = Number( + window.localStorage.getItem(FLOATING_PANEL_WIDTH_STORAGE_KEY) + ); + + if (Number.isFinite(storedWidth)) { + setFloatingPanelWidth( + clampFloatingPanelWidth(storedWidth, window.innerWidth) + ); + } + + setIsFloatingPanelWidthStorageReady(true); + }, []); + + useEffect(() => { + if (viewportWidth <= 0) { + return; + } + + setFloatingPanelWidth((currentWidth) => + clampFloatingPanelWidth(currentWidth, viewportWidth) + ); + }, [viewportWidth]); + + useEffect(() => { + if ( + !isFloatingPanelWidthStorageReady || + typeof window === "undefined" + ) { + return; + } + + window.localStorage.setItem( + FLOATING_PANEL_WIDTH_STORAGE_KEY, + String(Math.round(floatingPanelWidth)) + ); + }, [floatingPanelWidth, isFloatingPanelWidthStorageReady]); + useEffect(() => { if (!videoCanvasInsertMenu) return; @@ -10434,7 +10592,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { {isVideoMode ? null : (
+ {isPanelOpen ? ( +
+
+
+ ) : null}
{!areSecondaryPanelsReady ? ( diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 512952ac9..b6e4411c3 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -59,6 +59,10 @@ import { useCanvasStore } from "@/store/canvas"; import { cn } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { + downloadCanvasImageAsPng, + type CanvasImageDownloadProgress, +} from "@/lib/client/canvas-image-download"; import { canvasService } from "@/services/canvas"; import { inferOriginalImageUrl, @@ -125,6 +129,7 @@ import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-ic import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar"; import { ImageGenerationLoading } from "@/components/image-generation-loading"; +import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; import { Response } from "@/components/elements/response"; import type { VideoToolCapabilityContract } from "@/types/video-tool-capability"; import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel"; @@ -250,7 +255,6 @@ import { } from "@/components/canvas/chat-panel/canvas-chat-session"; import { buildCanvasHistoryMessagesSignature, - buildDownloadFilename, extractAssistantImageUrls, extractCanvasModelName, extractImageUrls, @@ -270,7 +274,6 @@ import { sortCanvasMessagesByCreatedAt, stripCanvasModelRoutingLines, stripMediaMarkup, - triggerBrowserDownload, } from "@/components/canvas/chat-panel/canvas-chat-media"; // 标签悬停预览信息 @@ -1653,6 +1656,10 @@ export function CanvasChatPanel({ url: string; alt?: string; } | null>(null); + const [downloadProgress, setDownloadProgress] = + useState(null); + const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState(""); + const downloadAbortControllerRef = useRef(null); const fullscreenPreviewSrc = useMemo(() => { if (!fullscreenPreview?.url) return ""; const originalUrl = inferOriginalImageUrl(fullscreenPreview.url); @@ -2129,41 +2136,62 @@ export function CanvasChatPanel({ ); const { mutate } = useSWRConfig(); const [votingMessageId, setVotingMessageId] = useState(null); + const isDownloadingOriginalImage = Boolean(downloadProgress); const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => { const raw = String(imageUrl || "").trim(); if (!raw) { toast.error("图片地址无效"); return; } + if (downloadAbortControllerRef.current) { + toast.info("原图正在下载中"); + return; + } const originalUrl = inferOriginalImageUrl(raw) || raw; - const filename = buildDownloadFilename(originalUrl); - try { - const response = await fetch( - buildImageProxyUrl(originalUrl, { delivery: "proxy" }) - ); - if (!response.ok) { - throw new Error(`download failed: ${response.status}`); - } - const blob = await response.blob(); - const blobUrl = window.URL.createObjectURL(blob); - triggerBrowserDownload(blobUrl, filename); - window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000); - return; - } catch (error) { - console.error("下载原图失败:", error); - try { - triggerBrowserDownload(originalUrl, filename); - toast.message("代理下载失败,已切换为原图直链下载"); - return; - } catch (fallbackError) { - console.error("原图直链下载失败:", fallbackError); - } + const controller = new AbortController(); + downloadAbortControllerRef.current = controller; + setDownloadingOriginalUrl(raw); + setDownloadProgress({ + phase: "preparing", + message: "正在准备原图...", + percent: 5, + }); - triggerBrowserDownload(originalUrl, undefined, { openInNewTab: true }); - toast.message("已打开原图,可右键另存为"); + try { + await downloadCanvasImageAsPng({ + url: originalUrl, + candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })], + signal: controller.signal, + onProgress: setDownloadProgress, + }); + toast.success("原图下载成功"); + } catch (error) { + if (controller.signal.aborted) { + toast.message("已取消下载"); + } else { + console.error("下载原图失败:", error); + toast.error( + error instanceof Error + ? `${error.message},请稍后重试或重新生成图片` + : "图片源文件暂时不可访问,请稍后重试或重新生成图片" + ); + } + } finally { + if (downloadAbortControllerRef.current === controller) { + downloadAbortControllerRef.current = null; + } + setDownloadingOriginalUrl(""); + setDownloadProgress(null); } }, []); + + const handleCancelOriginalImageDownload = useCallback(() => { + downloadAbortControllerRef.current?.abort(); + downloadAbortControllerRef.current = null; + setDownloadingOriginalUrl(""); + setDownloadProgress(null); + }, []); // 从 API 加载历史消息 const { data: historyData, mutate: mutateHistoryData } = useSWR<{ @@ -3424,6 +3452,7 @@ export function CanvasChatPanel({ const [composerModeOverride, setComposerModeOverride] = useState(null); const activeComposerMode = composerModeOverride || computedComposerMode; + const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video"; const isClawdMode = activeComposerMode === "clawd" && isClawdModeEnabled; const agentSelectableModels = useMemo( () => @@ -3794,18 +3823,18 @@ export function CanvasChatPanel({ (mode: CanvasComposerMode) => cn( "h-11 cursor-pointer rounded-[12px] px-3 text-[16px] font-normal outline-none transition-colors focus:text-[#1f2937] dark:focus:text-white [&_svg]:!size-5", - isVideoWorkspace - ? "text-foreground focus:bg-background/80" + isVideoToolbarDark + ? "text-zinc-100 focus:bg-white/12 focus:text-white" : "text-[#2e3238] focus:bg-[#eef0f3] dark:text-zinc-100 dark:focus:bg-white/12", activeComposerMode === mode - ? isVideoWorkspace - ? "bg-background text-foreground" + ? isVideoToolbarDark + ? "bg-white/12 text-white" : "bg-[#eef0f3] text-[#1f2937] dark:bg-white/12 dark:text-white" - : isVideoWorkspace - ? "hover:bg-background/80" + : isVideoToolbarDark + ? "hover:bg-white/8" : "hover:bg-[#f5f6f7] dark:hover:bg-white/8" ), - [activeComposerMode, isVideoWorkspace] + [activeComposerMode, isVideoToolbarDark] ); const shouldMaskAgentRuntimeModelName = activeComposerMode === "agent" || selectedModelId === "auto"; @@ -11436,7 +11465,8 @@ export function CanvasChatPanel({ />
))} @@ -11583,7 +11617,8 @@ export function CanvasChatPanel({ + {!isWorkspacePresentation && shouldShowCanvasBanner ? (
fileInputRef.current?.click()} @@ -13100,8 +13144,8 @@ export function CanvasChatPanel({ variant="ghost" className={cn( "relative h-9 max-w-[122px] shrink-0 cursor-pointer rounded-full px-2.5 text-[15px] font-medium outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0", - isVideoWorkspace - ? "bg-muted/80 text-foreground hover:bg-muted dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700" + isVideoToolbarDark + ? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]" : "bg-[#eeeeef] text-[#20242a] hover:bg-[#e7e7e8] dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14" )} > @@ -13148,8 +13192,8 @@ export function CanvasChatPanel({ sideOffset={10} className={cn( "w-[200px] space-y-1 rounded-[18px] border border-[#d8dce3] bg-white p-2 text-[#20242a] shadow-[0_22px_54px_rgba(15,23,42,0.16)]", - isVideoWorkspace - ? "border border-border/60 bg-background/95 shadow-[0_14px_30px_-20px_rgba(15,23,42,0.35)] backdrop-blur-xl" + isVideoToolbarDark + ? "border-white/10 bg-zinc-900/95 text-zinc-100 shadow-[0_18px_44px_rgba(0,0,0,0.38)] backdrop-blur-xl" : "dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100" )} > @@ -13167,7 +13211,7 @@ export function CanvasChatPanel({ Agent {activeComposerMode === "agent" ? ( - + ) : null} ) : null} @@ -13179,7 +13223,7 @@ export function CanvasChatPanel({ {CLAW_MODE_LABEL} {activeComposerMode === "clawd" ? ( - + ) : null} ) : null} @@ -13197,7 +13241,7 @@ export function CanvasChatPanel({ {getToolbarItem("chatModeDetailMenu").label} {activeComposerMode === "detail" ? ( - + ) : null} ) : null} @@ -13214,7 +13258,7 @@ export function CanvasChatPanel({ 图像 {activeComposerMode === "image" ? ( - + ) : null} {activeComposerMode === "video" ? ( - + ) : null} @@ -13239,12 +13283,13 @@ export function CanvasChatPanel({ {isVideoModel ? (
-
+
@@ -13296,16 +13341,18 @@ export function CanvasChatPanel({ svg]:hidden", - isVideoWorkspace - ? "border border-[#cfd8e6] bg-white/85 text-zinc-700 hover:bg-white dark:border-white/12 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]" + isVideoToolbarDark + ? "border border-white/12 bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]" : "border border-[#cfd4dc] bg-white text-[#20242a] dark:border-white/12 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]" )} title={getToolbarItem("chatModelSelector").label} > {selectDisplayModel?.avatarUrl ? ( @@ -13323,7 +13370,10 @@ export function CanvasChatPanel({ /> ) : getToolbarItem("chatModelSelector").iconUrl ? ( @@ -13331,7 +13381,7 @@ export function CanvasChatPanel({ {(selectDisplayModel?.displayName || "A").slice(0, 1)} @@ -13347,8 +13397,8 @@ export function CanvasChatPanel({
- + {estimatedPointsCost.cost} {getToolbarItem("chatPoints").label} @@ -13371,10 +13421,10 @@ export function CanvasChatPanel({ isWorkspacePresentation ? "h-10 rounded-full px-4 transition-all duration-200 flex-shrink-0" : "h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0", - isVideoWorkspace + isVideoToolbarDark ? canSend - ? "border border-primary/20 bg-primary text-primary-foreground shadow-sm hover:bg-primary/90" - : "border border-border/60 bg-background text-muted-foreground hover:bg-background/90" + ? "border border-[#e7ff1a]/20 bg-[#e7ff1a] text-zinc-950 shadow-sm hover:bg-[#dff40f]" + : "border border-white/10 bg-[#242529] text-zinc-500 hover:bg-[#2c2d33]" : isDetailGenerationMode ? canSend ? "bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white shadow-md hover:shadow-lg hover:opacity-90 dark:from-violet-500 dark:to-fuchsia-500" @@ -13402,13 +13452,13 @@ export function CanvasChatPanel({ className={cn( "h-3.5 w-3.5", canSend && - (isVideoWorkspace || isDetailGenerationMode + (isVideoToolbarDark || isDetailGenerationMode ? "opacity-95" : "opacity-95") )} fallback={Sparkles} item={getToolbarItem("chatStartGeneration")} - active={canSend && (isVideoWorkspace || isDetailGenerationMode)} + active={canSend && (isVideoToolbarDark || isDetailGenerationMode)} /> {isWorkspacePresentation ? ( @@ -13422,7 +13472,7 @@ export function CanvasChatPanel({ className={cn( "h-3.5 w-3.5", canSend && - (isVideoWorkspace || isDetailGenerationMode + (isVideoToolbarDark || isDetailGenerationMode ? "opacity-95" : "opacity-95") )} @@ -13432,7 +13482,7 @@ export function CanvasChatPanel({ ? "chatStartGeneration" : "chatSend" )} - active={canSend && (isVideoWorkspace || isDetailGenerationMode)} + active={canSend && (isVideoToolbarDark || isDetailGenerationMode)} /> {isWorkspacePresentation ? ( diff --git a/components/canvas/CanvasImageDownloadProgressDialog.tsx b/components/canvas/CanvasImageDownloadProgressDialog.tsx new file mode 100644 index 000000000..04c13ff76 --- /dev/null +++ b/components/canvas/CanvasImageDownloadProgressDialog.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { createPortal } from "react-dom"; +import { Download, Loader2, X } from "lucide-react"; +import type { CanvasImageDownloadProgress } from "@/lib/client/canvas-image-download"; +import { cn } from "@/lib/utils"; + +type CanvasImageDownloadProgressDialogProps = { + progress: CanvasImageDownloadProgress | null; + onCancel?: () => void; +}; + +function formatBytes(bytes?: number) { + if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return ""; + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; +} + +export function CanvasImageDownloadProgressDialog({ + onCancel, + progress, +}: CanvasImageDownloadProgressDialogProps) { + if (!progress || typeof document === "undefined") return null; + + const percent = + typeof progress.percent === "number" && Number.isFinite(progress.percent) + ? Math.max(0, Math.min(100, Math.round(progress.percent))) + : null; + const loadedLabel = formatBytes(progress.loadedBytes); + const totalLabel = formatBytes(progress.totalBytes); + + return createPortal( +
+
+
+
+ + {progress.phase === "saving" ? ( + + ) : ( + + )} + +
+
下载原图
+
{progress.message}
+
+
+ {onCancel ? ( + + ) : null} +
+ +
+
+ {loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"} + {percent !== null ? `${percent}%` : "..."} +
+
+
+
+
+
+ +
, + document.body + ); +} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 326a40891..614d42e15 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -50,6 +50,7 @@ import { TextEditColorField } from "@/components/canvas/TextEditColorField"; import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer"; import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; +import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; import { areImageAdjustmentsEqual, buildImageAdjustmentFilterStyle, @@ -89,8 +90,6 @@ import { normalizeMediaUrl, resolveRegeneratePromptSeed, resolveOriginalImageUrl, - sanitizeFileName, - triggerBrowserDownload, } from "@/components/canvas/nodes/image-node/image-node-utils"; import { createLocalEditPromptDraftMap, @@ -164,6 +163,10 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { + downloadCanvasImageAsPng, + type CanvasImageDownloadProgress, +} from "@/lib/client/canvas-image-download"; import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2"; import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder"; import { @@ -488,21 +491,6 @@ const ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER = 0.72; const ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER = 0.46; const ANGLE_PANEL_SOURCE_WIDTH = 520; const ANGLE_PANEL_SOURCE_HEIGHT = 700; -const parseAttachmentFilename = (contentDisposition: string | null) => { - if (!contentDisposition) return ""; - const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); - if (encodedMatch?.[1]) { - try { - return decodeURIComponent(encodedMatch[1]); - } catch (_error) { - return encodedMatch[1]; - } - } - const quotedMatch = contentDisposition.match(/filename="([^"]+)"/i); - if (quotedMatch?.[1]) return quotedMatch[1]; - const plainMatch = contentDisposition.match(/filename=([^;]+)/i); - return plainMatch?.[1]?.trim() || ""; -}; const ANGLE_PANEL_PREVIEW_SIZE = 392; const ANGLE_PANEL_CAMERA_SCENE_SIZE = 384; const ANGLE_PANEL_WIREFRAME_RADIUS = 132; @@ -662,6 +650,9 @@ const CANVAS_3D_POLL_INTERVAL_MS = 2000; const CANVAS_3D_MAX_POLL_ATTEMPTS = 360; const AI_WASH_TASK_POLL_INTERVAL_MS = 2500; const AI_WASH_TASK_POLL_LIMIT = 80; +const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000; +const HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS = 2500; +const HYDRATED_IMAGE_TASK_POLL_LIMIT = 240; const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId( DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels ); @@ -1669,6 +1660,9 @@ export function ImageNode({ const [isUploading, setIsUploading] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); + const [downloadProgress, setDownloadProgress] = + useState(null); + const downloadAbortControllerRef = useRef(null); const [textEditPanelOpen, setTextEditPanelOpen] = useState(false); const [notePanelOpen, setNotePanelOpen] = useState(false); const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false); @@ -2021,6 +2015,7 @@ export function ImageNode({ Record >({}); const localEditRegionsRef = useRef(localEditRegions); + const hydratedImageTaskRecoveryRef = useRef(null); const updateNode = useCanvasStore((state) => state.updateNode); const deleteNode = useCanvasStore((state) => state.deleteNode); @@ -2039,6 +2034,128 @@ export function ImageNode({ (state) => state.clearImageNodeActionRequest ); const allNodes = useCanvasStore((state) => state.nodes); + useEffect(() => { + if (data.status !== "generating" || data.src) return; + + const metadata = + data.metadata && typeof data.metadata === "object" && !Array.isArray(data.metadata) + ? (data.metadata as Record) + : {}; + const taskId = String(metadata.taskId || "").trim(); + if (!taskId || hydratedImageTaskRecoveryRef.current === taskId) return; + + const createdAtTime = new Date(node.createdAt).getTime(); + if ( + Number.isFinite(createdAtTime) && + Date.now() - createdAtTime < HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS + ) { + return; + } + + let cancelled = false; + hydratedImageTaskRecoveryRef.current = taskId; + + const updateCurrentGeneratingTaskNode = ( + nextData: (currentData: ImageNodeData) => ImageNodeData + ) => { + const currentNode = useCanvasStore + .getState() + .nodes.find((item) => item.id === node.id); + if (!currentNode || currentNode.type !== "image") return; + + const currentData = currentNode.data as ImageNodeData; + const currentMetadata = + currentData.metadata && + typeof currentData.metadata === "object" && + !Array.isArray(currentData.metadata) + ? (currentData.metadata as Record) + : {}; + if ( + currentData.status !== "generating" || + currentData.src || + String(currentMetadata.taskId || "").trim() !== taskId + ) { + return; + } + + updateNode(node.id, { + data: nextData(currentData), + }); + }; + + const markFailed = (message: string) => { + updateCurrentGeneratingTaskNode((currentData) => ({ + ...currentData, + src: "", + status: "failed", + prompt: message, + title: currentData.title?.includes("失败") + ? currentData.title + : `${currentData.title || "图片生成"}-失败`, + metadata: { + ...(currentData.metadata || {}), + error: message, + taskId, + taskRecoveredAt: new Date().toISOString(), + taskRecoveryStatus: "failed", + }, + })); + }; + + void (async () => { + let lastError = ""; + + for (let attempt = 0; attempt < HYDRATED_IMAGE_TASK_POLL_LIMIT; attempt += 1) { + if (cancelled) return; + + const taskResult = await imageGenerationService.getImageTask(taskId); + const task = taskResult.data?.task; + if (!taskResult.success || !task) { + lastError = taskResult.error || "查询生图任务失败"; + } else { + const status = String(task.status || "").toLowerCase(); + if (status === "succeeded" || status === "completed") { + const image = task.result?.images?.[0]; + const imageUrl = image?.originalUrl || image?.thumbnailUrl || ""; + if (!imageUrl) { + markFailed("任务已完成但未返回图片"); + return; + } + + updateCurrentGeneratingTaskNode((currentData) => ({ + ...currentData, + src: imageUrl, + status: "completed", + prompt: task.result?.prompt || currentData.prompt || "图片生成结果", + model: task.result?.model || task.result?.modelRef || currentData.model, + metadata: { + ...(currentData.metadata || {}), + taskId, + taskRecoveredAt: new Date().toISOString(), + taskRecoveryStatus: "succeeded", + }, + })); + return; + } + + if (["failed", "cancelled", "expired", "banned"].includes(status)) { + markFailed(task.error || "图片生成任务失败"); + return; + } + } + + await sleep(HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS); + } + + if (!cancelled) { + markFailed(lastError || "图片生成任务等待超时,请重新发起生成"); + } + })(); + + return () => { + cancelled = true; + }; + }, [data.metadata, data.src, data.status, node.createdAt, node.id, updateNode]); const selectedTripoModelEntry = useMemo( () => tripoModelOptions.find((entry) => entry.id === selectedTripoModelVersion) || @@ -2276,26 +2393,53 @@ export function ImageNode({ () => Math.max(6, Math.min(10, Math.round(compareButtonVisualSize * 0.22))), [compareButtonVisualSize] ); - const displayImageWidth = useMemo( - () => - Math.max(640, Math.min(2200, Math.round((node.size.width || 320) * 2))), - [node.size.width] - ); + const shouldUseHighFidelityDisplayImage = + isSelected || + isHovered || + isCompareMode || + boxCutoutMode || + localEditMode || + cropPanelOpen || + imageAdjustPanelOpen || + fullscreenPreviewOpen || + isThisNodePicking; + const displayImageWidth = useMemo(() => { + const devicePixelRatio = + typeof window !== "undefined" + ? clampNumber(window.devicePixelRatio || 1, 1, 3) + : 2; + const zoomMultiplier = clampNumber( + viewportZoom || 1, + 0.75, + shouldUseHighFidelityDisplayImage ? 2.4 : 1.6 + ); + const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05; + const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640; + const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600; + return Math.max( + minWidth, + Math.min( + maxWidth, + Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier) + ) + ); + }, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]); + const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88; const displayImageSrc = useMemo( () => buildImageProxyUrl(data.src, { width: displayImageWidth, - quality: 86, + quality: displayImageQuality, format: "webp", }), - [data.src, displayImageWidth] + [data.src, displayImageQuality, displayImageWidth] ); const fullscreenPreviewSrc = useMemo(() => { const originalUrl = resolveOriginalImageUrl(data.src || ""); return buildImageProxyUrl(originalUrl || data.src || "", { delivery: "proxy", - width: 2400, - quality: 90, + width: 4096, + quality: 92, format: "webp", }); }, [data.src]); @@ -2303,10 +2447,10 @@ export function ImageNode({ () => buildImageProxyUrl(compareSource, { width: displayImageWidth, - quality: 86, + quality: displayImageQuality, format: "webp", }), - [compareSource, displayImageWidth] + [compareSource, displayImageQuality, displayImageWidth] ); const shouldRequestDisplayImage = hasEnteredViewport || @@ -8055,84 +8199,61 @@ export function ImageNode({ }; // 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。 + const isDownloadingOriginalImage = Boolean(downloadProgress); const handleDownload = async () => { if (!data.src) return; - const originalUrl = String(resolveOriginalImageUrl(data.src) || data.src || "").trim(); - const downloadCandidates = Array.from( - new Set( - [ - originalUrl, - data.src, - displayImageSrc, - fullscreenPreviewSrc, - buildImageProxyUrl(originalUrl || data.src || "", { - delivery: "proxy", - }), - ] - .map((value) => String(value || "").trim()) - .filter(Boolean) - ) - ); - if (!originalUrl) { - toast.error("下载失败,请重试"); + if (isDownloadingOriginalImage) { + toast.info("原图正在下载中"); return; } + const controller = new AbortController(); + downloadAbortControllerRef.current = controller; + setDownloadProgress({ + phase: "preparing", + message: "正在准备原图...", + percent: 5, + }); + try { - toast.info("正在生成 PS 兼容 PNG..."); - - const response = await fetch("/api/canvas/download-image", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - url: downloadCandidates[0], - urls: downloadCandidates, - name: displayTitle, - }), + await downloadCanvasImageAsPng({ + url: data.src, + name: displayTitle, + candidates: [ + displayImageSrc, + fullscreenPreviewSrc, + buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, { + delivery: "proxy", + }), + ], + signal: controller.signal, + onProgress: setDownloadProgress, }); - if (!response.ok) { - const payload = (await response.json().catch(() => null)) as { - error?: string; - } | null; - throw new Error(payload?.error || `下载失败(${response.status})`); - } - const blob = await response.blob(); - if (!blob.type.toLowerCase().includes("image/png")) { - throw new Error("下载结果不是标准 PNG"); - } - const filename = - parseAttachmentFilename(response.headers.get("content-disposition")) || - `${sanitizeFileName(displayTitle)}.png`; - - // 创建 Blob URL - const blobUrl = URL.createObjectURL(blob); - - // 创建下载链接 - const link = document.createElement("a"); - link.href = blobUrl; - link.download = filename; - - // 触发下载 - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // 释放 Blob URL - window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); - - toast.success("下载成功"); + toast.success("原图下载成功"); } catch (error) { - console.error("下载失败:", error); - toast.error( - error instanceof Error - ? `${error.message},源图可能已失效,请稍后重试或重新生成图片` - : "图片源文件暂时不可访问,请稍后重试或重新生成图片" - ); + if (controller.signal.aborted) { + toast.message("已取消下载"); + } else { + console.error("下载失败:", error); + toast.error( + error instanceof Error + ? `${error.message},源图可能已失效,请稍后重试或重新生成图片` + : "图片源文件暂时不可访问,请稍后重试或重新生成图片" + ); + } + } finally { + if (downloadAbortControllerRef.current === controller) { + downloadAbortControllerRef.current = null; + } + setDownloadProgress(null); } }; + const handleCancelDownload = () => { + downloadAbortControllerRef.current?.abort(); + setDownloadProgress(null); + }; + const imageAdjustOverlayStyle = useMemo(() => { if (boxCutoutMetrics) { return { @@ -9463,12 +9584,21 @@ export function ImageNode({ ) : null} {imageDownloadEnabled ? ( - - + { + void handleDownload(); + }} + > + {isDownloadingOriginalImage ? ( + + ) : ( + + )} {imageDownloadItem.label} ) : null} @@ -10571,17 +10701,23 @@ export function ImageNode({ {imageDownloadEnabled ? ( ) : null}
@@ -11604,6 +11740,24 @@ export function ImageNode({ /> + {/* 图片 */} , document.body )} + ); } diff --git a/components/video-params-bar.tsx b/components/video-params-bar.tsx index e774910ec..1396be477 100644 --- a/components/video-params-bar.tsx +++ b/components/video-params-bar.tsx @@ -87,6 +87,7 @@ interface VideoParamsBarProps { initialParams?: Partial; onParamsChange?: (params: VideoGenerationParams) => void; inline?: boolean; // 内联模式:只显示参数按钮,不显示外层容器 + inlineTheme?: "default" | "dark"; providerType?: string; capabilities?: VideoToolCapabilityContract | null; } @@ -297,6 +298,7 @@ export function VideoParamsBar({ initialParams, onParamsChange, inline = false, + inlineTheme = "default", providerType = "volcano-video", capabilities, }: VideoParamsBarProps) { @@ -440,45 +442,79 @@ export function VideoParamsBar({ if (inline) { const modeLabel = modeOptions.find((o) => o.value === params.mode)?.label || MODE_LABELS[params.mode]; const durationLabel = `${params.duration}s`; + const isDarkInline = inlineTheme === "dark"; + const panelClass = isDarkInline + ? "border-white/10 bg-[#15161a]/95 text-zinc-100 shadow-[0_18px_44px_rgba(0,0,0,0.38)]" + : "border-[#d8dde4] bg-[#f3f4f6]/95 text-[#2f3b48] shadow-[0_10px_24px_rgba(15,23,42,0.14)] dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100 dark:shadow-[0_18px_44px_rgba(0,0,0,0.38)]"; + const groupPanelClass = isDarkInline + ? "bg-white/8" + : "bg-[#eceff2]/90 dark:bg-white/8"; + const labelClass = isDarkInline + ? "text-zinc-500" + : "text-[#8a94a3] dark:text-zinc-500"; + const dividerClass = isDarkInline + ? "bg-white/12" + : "bg-[#cfd5de] dark:bg-white/12"; + const primaryTriggerClass = isDarkInline + ? "border-white/10 bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]" + : "border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#2f3b48] hover:bg-[#edeff2]/90 dark:border-white/10 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"; + const iconClass = isDarkInline + ? "text-zinc-100" + : "text-[#20262f] dark:text-zinc-100"; + const activeOptionClass = isDarkInline + ? "border-white/12 bg-white/10 text-white shadow-none" + : "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)] dark:border-white/12 dark:bg-white/10 dark:text-white dark:shadow-none"; + const inactiveOptionClass = isDarkInline + ? "border-transparent text-zinc-400 hover:bg-white/8 hover:text-zinc-100" + : "border-transparent text-[#5f6b7a] hover:bg-white/70 dark:text-zinc-400 dark:hover:bg-white/8 dark:hover:text-zinc-100"; return ( -
+
{/* 主参数面板:模式 | 比例 | 时长 */}
{/* 模式 */}
-

选择模式

-
+

选择模式

+
{modeOptions.map((option) => (
) : !showFpsControl && !showCameraMoveControl ? ( -
+
当前模型支持图生视频(1 张起始图),已自动按官方参数约束处理。
) : ( <> {showFpsControl && (
-

帧率

-
+

帧率

+
{FPS_OPTIONS.map((option) => (