Update canvas generation UI and downloads
This commit is contained in:
parent
41522cc481
commit
05387d08f3
@ -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<AgentGenerationResultModeType, string> =
|
||||
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<string | null>(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<HTMLDivElement>) => {
|
||||
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<string, string | null>) => {
|
||||
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 : (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10 transition-all duration-300 ease-in-out",
|
||||
"absolute z-10 ease-in-out",
|
||||
isFloatingPanelResizing
|
||||
? "transition-none"
|
||||
: "transition-all duration-300",
|
||||
isPanelOpen
|
||||
? "translate-x-0 opacity-100"
|
||||
: "pointer-events-none translate-x-[120%] opacity-0"
|
||||
@ -10444,10 +10605,34 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
top: DOCKED_PANEL_INSET,
|
||||
right: DOCKED_PANEL_INSET,
|
||||
bottom: DOCKED_PANEL_INSET,
|
||||
width: FLOATING_PANEL_WIDTH,
|
||||
width: resolvedPanelWidth,
|
||||
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
|
||||
}}
|
||||
>
|
||||
{isPanelOpen ? (
|
||||
<div
|
||||
aria-label="拖动调整对话框宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={Math.round(
|
||||
getMaxFloatingPanelWidth(viewportWidth)
|
||||
)}
|
||||
aria-valuemin={FLOATING_PANEL_WIDTH}
|
||||
aria-valuenow={Math.round(resolvedPanelWidth)}
|
||||
className="group absolute inset-y-0 left-[-6px] z-30 w-3 cursor-col-resize touch-none"
|
||||
onPointerDown={handleFloatingPanelResizePointerDown}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors",
|
||||
"group-hover:bg-sky-400/70 dark:group-hover:bg-sky-300/70",
|
||||
isFloatingPanelResizing &&
|
||||
"bg-sky-500 dark:bg-sky-300"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
||||
{!areSecondaryPanelsReady ? (
|
||||
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
||||
|
||||
@ -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<CanvasImageDownloadProgress | null>(null);
|
||||
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
|
||||
const downloadAbortControllerRef = useRef<AbortController | null>(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<string | null>(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<CanvasComposerMode | null>(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({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80"
|
||||
className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80 disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={isDownloadingOriginalImage}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@ -11444,8 +11474,12 @@ export function CanvasChatPanel({
|
||||
}}
|
||||
title="下载原图"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
原图
|
||||
{downloadingOriginalUrl === url ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{downloadingOriginalUrl === url ? "下载中" : "原图"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@ -11583,7 +11617,8 @@ export function CanvasChatPanel({
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/20 transition-colors"
|
||||
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={isDownloadingOriginalImage}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDownloadOriginalImage(fullscreenPreview.url);
|
||||
@ -11591,8 +11626,12 @@ export function CanvasChatPanel({
|
||||
aria-label="下载原图"
|
||||
title="下载原图"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
下载原图
|
||||
{isDownloadingOriginalImage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
||||
</button>
|
||||
|
||||
<img
|
||||
@ -11611,6 +11650,11 @@ export function CanvasChatPanel({
|
||||
document.body
|
||||
)}
|
||||
|
||||
<CanvasImageDownloadProgressDialog
|
||||
onCancel={handleCancelOriginalImageDownload}
|
||||
progress={downloadProgress}
|
||||
/>
|
||||
|
||||
{!isWorkspacePresentation && shouldShowCanvasBanner ? (
|
||||
<div className="px-[10px] pb-0">
|
||||
<div
|
||||
@ -13070,8 +13114,8 @@ export function CanvasChatPanel({
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-full border border-transparent text-[#20242a] transition-colors hover:bg-[#f3f4f6] hover:text-[#111418] dark:hover:border-zinc-500 dark:hover:bg-zinc-700/70",
|
||||
isVideoWorkspace
|
||||
? "bg-background"
|
||||
isVideoToolbarDark
|
||||
? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33] hover:text-white"
|
||||
: "dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
|
||||
)}
|
||||
onClick={() => 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
|
||||
</span>
|
||||
{activeComposerMode === "agent" ? (
|
||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
|
||||
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
@ -13179,7 +13223,7 @@ export function CanvasChatPanel({
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
<span className="flex-1">{CLAW_MODE_LABEL}</span>
|
||||
{activeComposerMode === "clawd" ? (
|
||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
|
||||
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
@ -13197,7 +13241,7 @@ export function CanvasChatPanel({
|
||||
{getToolbarItem("chatModeDetailMenu").label}
|
||||
</span>
|
||||
{activeComposerMode === "detail" ? (
|
||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
|
||||
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
@ -13214,7 +13258,7 @@ export function CanvasChatPanel({
|
||||
图像
|
||||
</span>
|
||||
{activeComposerMode === "image" ? (
|
||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
|
||||
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@ -13230,7 +13274,7 @@ export function CanvasChatPanel({
|
||||
视频
|
||||
</span>
|
||||
{activeComposerMode === "video" ? (
|
||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
|
||||
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@ -13239,12 +13283,13 @@ export function CanvasChatPanel({
|
||||
|
||||
{isVideoModel ? (
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
||||
<div className="w-max pr-1 pl-1">
|
||||
<div className="w-max px-1">
|
||||
<VideoParamsBar
|
||||
modelName={currentModel?.displayName}
|
||||
initialParams={videoParams || undefined}
|
||||
onParamsChange={handleVideoParamsChange}
|
||||
inline={true}
|
||||
inlineTheme={isVideoToolbarDark ? "dark" : "default"}
|
||||
providerType={currentModel?.providerType}
|
||||
capabilities={currentModel?.videoToolCapability}
|
||||
/>
|
||||
@ -13296,16 +13341,18 @@ export function CanvasChatPanel({
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 justify-center rounded-full p-0 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 [&>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}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white text-zinc-700 leading-none ring-1 ring-black/5 dark:bg-white dark:text-zinc-900 dark:ring-white/20",
|
||||
isVideoWorkspace ? "border border-border/60" : "border border-transparent"
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full leading-none ring-1",
|
||||
isVideoToolbarDark
|
||||
? "border border-white/12 bg-[#15161a] text-zinc-100 ring-white/12"
|
||||
: "border border-transparent bg-white text-zinc-700 ring-black/5 dark:bg-white dark:text-zinc-900 dark:ring-white/20"
|
||||
)}
|
||||
>
|
||||
{selectDisplayModel?.avatarUrl ? (
|
||||
@ -13323,7 +13370,10 @@ export function CanvasChatPanel({
|
||||
/>
|
||||
) : getToolbarItem("chatModelSelector").iconUrl ? (
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4 text-zinc-700 dark:text-zinc-900"
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isVideoToolbarDark ? "text-zinc-100" : "text-zinc-700 dark:text-zinc-900"
|
||||
)}
|
||||
fallback={Bot}
|
||||
item={getToolbarItem("chatModelSelector")}
|
||||
/>
|
||||
@ -13331,7 +13381,7 @@ export function CanvasChatPanel({
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center text-[10px] font-medium leading-none",
|
||||
isVideoWorkspace ? "text-zinc-700" : "text-[#4b5563] dark:text-zinc-900"
|
||||
isVideoToolbarDark ? "text-zinc-100" : "text-[#4b5563] dark:text-zinc-900"
|
||||
)}
|
||||
>
|
||||
{(selectDisplayModel?.displayName || "A").slice(0, 1)}
|
||||
@ -13347,8 +13397,8 @@ export function CanvasChatPanel({
|
||||
<div
|
||||
className={cn(
|
||||
"ml-0 flex h-7 max-w-[60px] shrink items-center gap-1 rounded-full px-2 text-[10px] sm:ml-1 sm:max-w-none sm:gap-1 sm:px-2 sm:text-[11px]",
|
||||
isVideoWorkspace
|
||||
? "border border-border/60 bg-background text-muted-foreground"
|
||||
isVideoToolbarDark
|
||||
? "border border-white/12 bg-[#242529] text-zinc-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
title={estimatedPointsCost.title}
|
||||
@ -13358,7 +13408,7 @@ export function CanvasChatPanel({
|
||||
fallback={Sparkles}
|
||||
item={getToolbarItem("chatPoints")}
|
||||
/>
|
||||
<span className="font-medium text-foreground">
|
||||
<span className={cn("font-medium", isVideoToolbarDark ? "text-zinc-100" : "text-foreground")}>
|
||||
{estimatedPointsCost.cost}
|
||||
</span>
|
||||
<span className="hidden sm:inline">{getToolbarItem("chatPoints").label}</span>
|
||||
@ -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 ? (
|
||||
<span className="ml-2">
|
||||
@ -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 ? (
|
||||
<span className="ml-2">
|
||||
|
||||
93
components/canvas/CanvasImageDownloadProgressDialog.tsx
Normal file
93
components/canvas/CanvasImageDownloadProgressDialog.tsx
Normal file
@ -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(
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/42 px-4 backdrop-blur-[2px]"
|
||||
data-canvas-download-progress="true"
|
||||
>
|
||||
<div className="w-full max-w-[380px] rounded-[18px] border border-white/12 bg-[#111317] p-5 text-white shadow-[0_24px_80px_rgba(0,0,0,0.42)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/10 text-cyan-200">
|
||||
{progress.phase === "saving" ? (
|
||||
<Download className="h-5 w-5" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[15px] font-semibold">下载原图</div>
|
||||
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{onCancel ? (
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
onClick={onCancel}
|
||||
title="取消下载"
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
|
||||
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
|
||||
<span>{percent !== null ? `${percent}%` : "..."}</span>
|
||||
</div>
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
|
||||
percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
|
||||
)}
|
||||
style={percent !== null ? { width: `${percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
@keyframes download-indeterminate {
|
||||
0% {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(320%);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@ -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<CanvasImageDownloadProgress | null>(null);
|
||||
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
|
||||
const [notePanelOpen, setNotePanelOpen] = useState(false);
|
||||
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
|
||||
@ -2021,6 +2015,7 @@ export function ImageNode({
|
||||
Record<string, LocalEditPromptCompositionSnapshot | undefined>
|
||||
>({});
|
||||
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
|
||||
const hydratedImageTaskRecoveryRef = useRef<string | null>(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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<React.CSSProperties>(() => {
|
||||
if (boxCutoutMetrics) {
|
||||
return {
|
||||
@ -9463,12 +9584,21 @@ export function ImageNode({
|
||||
<DropdownMenuSeparator />
|
||||
) : null}
|
||||
{imageDownloadEnabled ? (
|
||||
<DropdownMenuItem onClick={handleDownload}>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Download}
|
||||
item={imageDownloadItem}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
disabled={isDownloadingOriginalImage}
|
||||
onClick={() => {
|
||||
void handleDownload();
|
||||
}}
|
||||
>
|
||||
{isDownloadingOriginalImage ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Download}
|
||||
item={imageDownloadItem}
|
||||
/>
|
||||
)}
|
||||
{imageDownloadItem.label}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
@ -10571,17 +10701,23 @@ export function ImageNode({
|
||||
{imageDownloadEnabled ? (
|
||||
<Button
|
||||
className="h-10 w-10 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing || isDownloadingOriginalImage}
|
||||
onClick={() => {
|
||||
void handleDownload();
|
||||
}}
|
||||
size="icon"
|
||||
title={imageDownloadItem.label}
|
||||
variant="ghost"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Download}
|
||||
item={imageDownloadItem}
|
||||
/>
|
||||
{isDownloadingOriginalImage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Download}
|
||||
item={imageDownloadItem}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@ -11604,6 +11740,24 @@ export function ImageNode({
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isDownloadingOriginalImage}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDownload();
|
||||
}}
|
||||
title="下载原图"
|
||||
type="button"
|
||||
>
|
||||
{isDownloadingOriginalImage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
下载原图
|
||||
</button>
|
||||
|
||||
{/* 图片 */}
|
||||
<img
|
||||
@ -11623,6 +11777,10 @@ export function ImageNode({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<CanvasImageDownloadProgressDialog
|
||||
onCancel={handleCancelDownload}
|
||||
progress={downloadProgress}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -87,6 +87,7 @@ interface VideoParamsBarProps {
|
||||
initialParams?: Partial<VideoGenerationParams>;
|
||||
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 (
|
||||
<div className="ml-1 flex items-center gap-1 border-l border-border/50 pl-1">
|
||||
<div
|
||||
className={cn(
|
||||
"ml-1 flex w-max shrink-0 items-center gap-1 border-l pl-1",
|
||||
isDarkInline ? "border-white/10" : "border-border/50"
|
||||
)}
|
||||
>
|
||||
{/* 主参数面板:模式 | 比例 | 时长 */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 max-w-[200px] shrink rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2 text-[11px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90"
|
||||
className={cn(
|
||||
"h-8 shrink-0 justify-start rounded-[12px] border px-2 text-[11px] font-semibold",
|
||||
primaryTriggerClass
|
||||
)}
|
||||
title={`${modeLabel},${params.ratio},${durationLabel}`}
|
||||
>
|
||||
<Clapperboard className="mr-1 h-3.5 w-3.5 text-[#20262f]" />
|
||||
<span className="truncate">{modeLabel}</span>
|
||||
<span className="mx-1.5 h-3 w-px bg-[#cfd5de]" />
|
||||
<span className="truncate">{params.ratio}</span>
|
||||
<span className="mx-1.5 h-3 w-px bg-[#cfd5de]" />
|
||||
<span className="truncate">{durationLabel}</span>
|
||||
<Clapperboard className={cn("mr-1 h-3.5 w-3.5 shrink-0", iconClass)} />
|
||||
<span className="whitespace-nowrap">{modeLabel}</span>
|
||||
<span className={cn("mx-1.5 h-3 w-px shrink-0", dividerClass)} />
|
||||
<span className="shrink-0">{params.ratio}</span>
|
||||
<span className={cn("mx-1.5 h-3 w-px shrink-0", dividerClass)} />
|
||||
<span className="shrink-0">{durationLabel}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className="w-[312px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
|
||||
className={cn(
|
||||
"w-[312px] max-w-[calc(100vw-24px)] rounded-[14px] border p-2",
|
||||
panelClass
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
{/* 模式 */}
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">选择模式</p>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>选择模式</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
{modeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
|
||||
params.mode === option.value
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
params.mode === option.value ? activeOptionClass : inactiveOptionClass
|
||||
)}
|
||||
onClick={() => updateParam("mode", option.value)}
|
||||
>
|
||||
@ -490,17 +526,15 @@ export function VideoParamsBar({
|
||||
|
||||
{/* 比例 */}
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">选择比例</p>
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>选择比例</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
{ratioOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
|
||||
params.ratio === option.value
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
params.ratio === option.value ? activeOptionClass : inactiveOptionClass
|
||||
)}
|
||||
onClick={() => updateParam("ratio", option.value as VideoGenerationParams["ratio"])}
|
||||
>
|
||||
@ -513,8 +547,8 @@ export function VideoParamsBar({
|
||||
{/* 分辨率(非 fal) */}
|
||||
{showResolutionControl && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">选择分辨率</p>
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1">
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>选择分辨率</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1", groupPanelClass)}>
|
||||
{resolutionOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@ -522,8 +556,8 @@ export function VideoParamsBar({
|
||||
className={cn(
|
||||
"h-8 rounded-[9px] text-[12px] font-semibold transition",
|
||||
params.resolution === option.value
|
||||
? "bg-white text-[#111827] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "bg-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
? activeOptionClass
|
||||
: inactiveOptionClass
|
||||
)}
|
||||
onClick={() => updateParam("resolution", option.value)}
|
||||
>
|
||||
@ -536,9 +570,10 @@ export function VideoParamsBar({
|
||||
|
||||
{/* 时长 */}
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">选择时长</p>
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>选择时长</p>
|
||||
<div className={cn(
|
||||
"mt-1.5 grid gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5",
|
||||
"mt-1.5 grid gap-1.5 rounded-[12px] p-1.5",
|
||||
groupPanelClass,
|
||||
durationOptions.length > 6 ? "grid-cols-5" : "grid-cols-2"
|
||||
)}>
|
||||
{durationOptions.map((duration) => (
|
||||
@ -547,9 +582,7 @@ export function VideoParamsBar({
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
|
||||
params.duration === duration
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
params.duration === duration ? activeOptionClass : inactiveOptionClass
|
||||
)}
|
||||
onClick={() => updateParam("duration", duration)}
|
||||
>
|
||||
@ -567,7 +600,12 @@ export function VideoParamsBar({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-[12px] border p-0",
|
||||
isDarkInline
|
||||
? "border-white/10 bg-[#242529] text-zinc-300 hover:bg-[#2c2d33] hover:text-zinc-100"
|
||||
: "border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#5f6b7a] hover:bg-[#edeff2]/90 dark:border-white/10 dark:bg-[#242529] dark:text-zinc-300 dark:hover:bg-[#2c2d33] dark:hover:text-zinc-100"
|
||||
)}
|
||||
title="更多参数"
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
@ -577,20 +615,23 @@ export function VideoParamsBar({
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className="w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
|
||||
className={cn(
|
||||
"w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border p-2",
|
||||
panelClass
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
{showGenerateAudioControl ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">音频</p>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>音频</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center gap-1.5 rounded-[9px] border text-[12px] font-semibold transition",
|
||||
(params.generateAudio ?? true)
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
? activeOptionClass
|
||||
: inactiveOptionClass
|
||||
)}
|
||||
onClick={() => updateParam("generateAudio", true)}
|
||||
>
|
||||
@ -602,8 +643,8 @@ export function VideoParamsBar({
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center gap-1.5 rounded-[9px] border text-[12px] font-semibold transition",
|
||||
params.generateAudio === false
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
? activeOptionClass
|
||||
: inactiveOptionClass
|
||||
)}
|
||||
onClick={() => updateParam("generateAudio", false)}
|
||||
>
|
||||
@ -613,15 +654,22 @@ export function VideoParamsBar({
|
||||
</div>
|
||||
</div>
|
||||
) : !showFpsControl && !showCameraMoveControl ? (
|
||||
<div className="rounded-[12px] bg-[#eceff2]/90 p-2 text-[11px] text-[#7b8796]">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[12px] p-2 text-[11px]",
|
||||
isDarkInline
|
||||
? "bg-white/8 text-zinc-400"
|
||||
: "bg-[#eceff2]/90 text-[#7b8796] dark:bg-white/8 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
当前模型支持图生视频(1 张起始图),已自动按官方参数约束处理。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showFpsControl && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">帧率</p>
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>帧率</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
{FPS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@ -629,8 +677,8 @@ export function VideoParamsBar({
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
|
||||
(params.fps || 24) === option.value
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
? activeOptionClass
|
||||
: inactiveOptionClass
|
||||
)}
|
||||
onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}
|
||||
>
|
||||
@ -643,8 +691,8 @@ export function VideoParamsBar({
|
||||
|
||||
{showCameraMoveControl && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">运镜模式</p>
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>运镜模式</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
{CAMERA_MOVE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@ -652,8 +700,8 @@ export function VideoParamsBar({
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-[9px] border text-[11px] font-semibold transition",
|
||||
params.cameraMove === option.value
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
? activeOptionClass
|
||||
: inactiveOptionClass
|
||||
)}
|
||||
onClick={() => {
|
||||
updateParam("cameraMove", option.value);
|
||||
@ -670,10 +718,15 @@ export function VideoParamsBar({
|
||||
)}
|
||||
|
||||
{/* 高级选项 */}
|
||||
<div className="space-y-2.5 border-t border-[#dce1e8] pt-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2.5 border-t pt-2.5",
|
||||
isDarkInline ? "border-white/10" : "border-[#dce1e8] dark:border-white/10"
|
||||
)}
|
||||
>
|
||||
{showReturnLastFrameControl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-[#8a94a3]">返回尾帧</span>
|
||||
<span className={cn("text-[11px] font-medium", labelClass)}>返回尾帧</span>
|
||||
<Switch
|
||||
checked={params.returnLastFrame || false}
|
||||
onCheckedChange={(checked) => updateParam("returnLastFrame", checked)}
|
||||
@ -682,7 +735,7 @@ export function VideoParamsBar({
|
||||
)}
|
||||
{showGenerateAudioControl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-[#8a94a3]">生成音频</span>
|
||||
<span className={cn("text-[11px] font-medium", labelClass)}>生成音频</span>
|
||||
<Switch
|
||||
checked={params.generateAudio ?? true}
|
||||
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
|
||||
@ -691,13 +744,18 @@ export function VideoParamsBar({
|
||||
)}
|
||||
{showSeedControl && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-[#8a94a3]">随机种子</span>
|
||||
<span className={cn("text-[11px] font-medium", labelClass)}>随机种子</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="留空则随机"
|
||||
value={params.seed || ""}
|
||||
onChange={(e) => updateParam("seed", Number(e.target.value) || undefined)}
|
||||
className="h-7 rounded-[8px] border-[#dce1e8] bg-[#eef1f4]/90 text-[12px] text-[#2f3b48] placeholder:text-[#b0b8c4]"
|
||||
className={cn(
|
||||
"h-7 rounded-[8px] text-[12px]",
|
||||
isDarkInline
|
||||
? "border-white/10 bg-white/8 text-zinc-100 placeholder:text-zinc-500"
|
||||
: "border-[#dce1e8] bg-[#eef1f4]/90 text-[#2f3b48] placeholder:text-[#b0b8c4] dark:border-white/10 dark:bg-white/8 dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -380,7 +380,9 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(canvasImageTask.id, entry.taskId));
|
||||
.where(
|
||||
and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running"))
|
||||
);
|
||||
} catch (error) {
|
||||
await db
|
||||
.update(canvasImageTask)
|
||||
@ -390,7 +392,9 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(canvasImageTask.id, entry.taskId));
|
||||
.where(
|
||||
and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running"))
|
||||
);
|
||||
} finally {
|
||||
const modelKey = releaseRunningTask(entry.taskId);
|
||||
if (modelKey) {
|
||||
@ -597,29 +601,35 @@ export async function getCanvasImageTaskForUser(params: {
|
||||
if (task.status === "running" && task.startedAt) {
|
||||
const staleBefore = new Date(Date.now() - STALE_RUNNING_MS);
|
||||
if (task.startedAt < staleBefore) {
|
||||
await db
|
||||
const timeoutMessage = "图片生成任务已超时,请重新发起生成";
|
||||
const completedAt = new Date();
|
||||
const [timedOutTask] = await db
|
||||
.update(canvasImageTask)
|
||||
.set({
|
||||
status: "pending",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
updatedAt: new Date(),
|
||||
status: "failed",
|
||||
error: timeoutMessage,
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(canvasImageTask.id, task.id),
|
||||
eq(canvasImageTask.status, "running"),
|
||||
lt(canvasImageTask.startedAt, staleBefore)
|
||||
)
|
||||
)
|
||||
);
|
||||
await enqueueLocalTask(task.id, {
|
||||
modelKey: normalizeTaskModelKey(task.request),
|
||||
userId: params.userId,
|
||||
});
|
||||
await drainCanvasImageTaskQueue();
|
||||
task.status = "pending" as CanvasImageTaskStatus;
|
||||
task.startedAt = null;
|
||||
task.completedAt = null;
|
||||
.returning({
|
||||
status: canvasImageTask.status,
|
||||
error: canvasImageTask.error,
|
||||
completedAt: canvasImageTask.completedAt,
|
||||
});
|
||||
|
||||
if (timedOutTask) {
|
||||
releaseRunningTask(task.id);
|
||||
task.status = timedOutTask.status as CanvasImageTaskStatus;
|
||||
task.error = timedOutTask.error;
|
||||
task.completedAt = timedOutTask.completedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
188
lib/client/canvas-image-download.ts
Normal file
188
lib/client/canvas-image-download.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
|
||||
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
||||
|
||||
export type CanvasImageDownloadProgress = {
|
||||
phase: CanvasImageDownloadPhase;
|
||||
message: string;
|
||||
loadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
percent?: number;
|
||||
};
|
||||
|
||||
export type CanvasImageDownloadResult = {
|
||||
filename: string;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
type DownloadCanvasImageParams = {
|
||||
url: string;
|
||||
name?: string;
|
||||
candidates?: string[];
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: CanvasImageDownloadProgress) => void;
|
||||
};
|
||||
|
||||
function parseAttachmentFilename(contentDisposition: string | null) {
|
||||
if (!contentDisposition) return "";
|
||||
const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(encodedMatch[1]);
|
||||
} catch {
|
||||
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() || "";
|
||||
}
|
||||
|
||||
function sanitizeDownloadName(value: string, fallback: string) {
|
||||
const cleaned = String(value || "")
|
||||
.replace(/\.[a-zA-Z0-9]{2,6}$/g, "")
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function inferDownloadNameFromUrl(url: string) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname || "";
|
||||
return pathname.split("/").pop() || "";
|
||||
} catch {
|
||||
return String(url || "").split("/").pop()?.split("?")[0] || "";
|
||||
}
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
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);
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
}
|
||||
|
||||
function toDownloadCandidates(url: string, candidates: string[] = []) {
|
||||
const raw = String(url || "").trim();
|
||||
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
|
||||
return Array.from(
|
||||
new Set(
|
||||
[
|
||||
originalUrl,
|
||||
raw,
|
||||
...candidates,
|
||||
originalUrl ? buildImageProxyUrl(originalUrl, { delivery: "proxy" }) : "",
|
||||
]
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function readResponseBlobWithProgress(
|
||||
response: Response,
|
||||
onProgress?: (progress: CanvasImageDownloadProgress) => void
|
||||
) {
|
||||
const totalBytes = Number(response.headers.get("content-length") || 0);
|
||||
if (!response.body) {
|
||||
const blob = await response.blob();
|
||||
onProgress?.({
|
||||
phase: "downloading",
|
||||
message: "正在下载原图...",
|
||||
loadedBytes: blob.size,
|
||||
totalBytes: totalBytes || blob.size,
|
||||
percent: 100,
|
||||
});
|
||||
return blob;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let loadedBytes = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
chunks.push(value);
|
||||
loadedBytes += value.byteLength;
|
||||
onProgress?.({
|
||||
phase: "downloading",
|
||||
message: "正在下载原图...",
|
||||
loadedBytes,
|
||||
totalBytes: totalBytes || undefined,
|
||||
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return new Blob(chunks, {
|
||||
type: response.headers.get("content-type") || "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadCanvasImageAsPng({
|
||||
candidates,
|
||||
name,
|
||||
onProgress,
|
||||
signal,
|
||||
url,
|
||||
}: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||
const raw = String(url || "").trim();
|
||||
if (!raw) {
|
||||
throw new Error("图片地址无效");
|
||||
}
|
||||
|
||||
const urls = toDownloadCandidates(raw, candidates);
|
||||
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
|
||||
const filenameBase = sanitizeDownloadName(
|
||||
name || inferDownloadNameFromUrl(originalUrl || raw),
|
||||
"canvas-image"
|
||||
);
|
||||
|
||||
onProgress?.({
|
||||
phase: "preparing",
|
||||
message: "正在准备原图...",
|
||||
percent: 8,
|
||||
});
|
||||
|
||||
const response = await fetch("/api/canvas/download-image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: urls[0],
|
||||
urls,
|
||||
name: filenameBase,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(payload?.error || `下载失败(${response.status})`);
|
||||
}
|
||||
|
||||
const filename =
|
||||
parseAttachmentFilename(response.headers.get("content-disposition")) ||
|
||||
`${filenameBase}.png`;
|
||||
const blob = await readResponseBlobWithProgress(response, onProgress);
|
||||
|
||||
onProgress?.({
|
||||
phase: "saving",
|
||||
message: "正在保存文件...",
|
||||
loadedBytes: blob.size,
|
||||
totalBytes: blob.size,
|
||||
percent: 100,
|
||||
});
|
||||
triggerBlobDownload(blob, filename);
|
||||
return { filename, bytes: blob.size };
|
||||
}
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user