fix(canvas): 画布媒体 - 修复预览首帧音量和创作状态展示

This commit is contained in:
Codex Audit committed 2026-09-23 11:10:45 +08:00
1 parent 3a2a794e8d
commit 92bbab8257
30 files changed
+689 -182

No files matched your search

@@ -1,14 +1,16 @@
import { useEffect, useState } from "react";
import { Button, Modal } from "antd";
import { XCircle } from "lucide-react";
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
import { TaskDetailItem } from "./canvas-project-feedback";
import { generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/generation-task-display";
import { canCancelGenerationTask, generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/generation-task-display";
import { formatTaskLog, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { VideoPlayer } from "@/components/video-player";
import { AppModal } from "@/components/ui/product/app-modal";
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
import { resolveMediaUrl } from "@/services/file-storage";
type CanvasProjectStatusDialogsProps = {
theme: { node: { stroke: string; panel: string; muted: string; fill: string } };
@@ -52,7 +54,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
</div>
</div>
<TaskGenerationParameters inputJson={task.inputJson} theme={theme} />
{onCancelTask && (task.status === "queued" || task.status === "running") ? (
{onCancelTask && canCancelGenerationTask(task) ? (
<div className="flex justify-end">
<Button danger icon={<XCircle className="size-4" />} onClick={() => onCancelTask(task)}>
取消任务
@@ -77,7 +79,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
<AppModal
title="视频预览"
open={Boolean(previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video)}
open={Boolean(previewNode?.type === CanvasNodeType.Video && (previewNode.metadata?.content || previewNode.metadata?.storageKey))}
centered
onCancel={onClosePreview}
footer={null}
@@ -85,14 +87,15 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
flush
styles={{ body: { display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "var(--workspace-canvas-deep)" } }}
>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? (
<VideoPlayer src={previewNode.metadata.content} mimeType={previewNode.metadata.mimeType} title={previewNode.title || "视频预览"} hasAudio={typeof previewNode.metadata.hasAudio === "boolean" ? previewNode.metadata.hasAudio : undefined} className="max-h-[84vh] max-w-full bg-black" />
{previewNode?.type === CanvasNodeType.Video && (previewNode.metadata?.content || previewNode.metadata?.storageKey) ? (
<ResolvedCanvasVideoPreview node={previewNode} />
) : null}
</AppModal>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Image ? (
{previewNode?.type === CanvasNodeType.Image && (previewNode.metadata?.content || previewNode.metadata?.storageKey) ? (
<CanvasImagePreview
src={previewNode.metadata.content}
storageKey={previewNode.metadata.storageKey}
alt={previewNode.title || "图片"}
onClose={onClosePreview}
/>
@@ -118,6 +121,34 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
);
}
function ResolvedCanvasVideoPreview({ node }: { node: CanvasNodeData }) {
const [src, setSrc] = useState(node.metadata?.storageKey ? "" : node.metadata?.content || "");
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setSrc(node.metadata?.storageKey ? "" : node.metadata?.content || "");
setFailed(false);
void resolveMediaUrl(node.metadata?.storageKey, node.metadata?.content || "")
.then((url) => {
if (!cancelled) {
setSrc(url);
setFailed(!url);
}
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
};
}, [node.metadata?.content, node.metadata?.storageKey]);
if (failed) return <div className="grid min-h-72 w-full place-items-center text-sm text-white/55">视频地址获取失败,请关闭后重试</div>;
if (!src) return <div className="grid min-h-72 w-full place-items-center text-sm text-white/55">正在加载视频…</div>;
return <VideoPlayer src={src} mimeType={node.metadata?.mimeType} title={node.title || "视频预览"} hasAudio={typeof node.metadata?.hasAudio === "boolean" ? node.metadata.hasAudio : undefined} className="max-h-[84vh] max-w-full bg-black" />;
}
function TaskGenerationParameters({ inputJson, theme }: { inputJson?: string; theme: CanvasProjectStatusDialogsProps["theme"] }) {
const fields = taskParameterRows(inputJson);
return (
+19 -2
View File
@@ -1,6 +1,7 @@
import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel";
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
import { canCancelGenerationTask } from "@/lib/generation-task-display";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -570,6 +571,10 @@ function InfiniteCanvasPage() {
const cancelCanvasTask = useCallback(
(task: import("@/services/api/task-center").GenerationTask) => {
if (!canCancelGenerationTask(task)) {
message.info("第三方请求已提交,任务将继续创作,不能取消");
return;
}
modal.confirm({
title: "取消生成任务?",
content: "任务会立即停止本地执行;如果已经提交到上游,系统会继续核对取消结果和积分状态。",
@@ -577,6 +582,11 @@ function InfiniteCanvasPage() {
okButtonProps: { danger: true },
cancelText: "继续等待",
onOk: async () => {
const latestTask = taskDetail?.id === task.id ? taskDetail : task;
if (!canCancelGenerationTask(latestTask)) {
message.info("第三方请求已提交,任务将继续创作,不能取消");
return;
}
try {
const next = await cancelGenerationTask(task.id);
const node = nodesRef.current.find((item) => item.metadata?.taskId === task.id);
@@ -590,7 +600,7 @@ function InfiniteCanvasPage() {
},
});
},
[bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail],
[bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail, taskDetail],
);
useEffect(() => {
@@ -1331,6 +1341,13 @@ function InfiniteCanvasPage() {
const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
const remoteResourceId = resourceIdFromStorageKey(asset.storageKey);
const storageKey = characterCover
? `resource:${characterCover.resourceId}`
: local?.kind === "image" || local?.kind === "video" || local?.kind === "audio"
? local.data.storageKey
: remoteResourceId
? asset.storageKey
: undefined;
const content = characterCover
? resourceFileUrl(characterCover.resourceId)
: local?.kind === "image"
@@ -1342,7 +1359,7 @@ function InfiniteCanvasPage() {
: remoteResourceId
? resourceFileUrl(remoteResourceId)
: asset.previewText || "";
const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content } };
const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content, storageKey } };
const current = result.get(asset.folderId) || [];
current.push(preview);
result.set(asset.folderId, current);
+13 -5
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { saveAs } from "file-saver";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isCanvasFolderNode, isFrameNode } from "@/lib/canvas/canvas-frame";
@@ -10,6 +9,7 @@ import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
import { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain";
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { downloadBrowserMedia } from "@/services/browser-download";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
@@ -196,10 +196,18 @@ export function useCanvasNodeEditor({
.catch((error) => message.error(error instanceof Error ? error.message : "资产分类更新失败"));
}, [canvasId, domainProjectId, message, nodesRef, queryClient, setNodes]);
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return;
saveAs(node.metadata.content, buildCanvasMediaDownloadFileName(canvasTitle, node));
}, [canvasTitle]);
const downloadNodeImage = useCallback(async (node: CanvasNodeData) => {
const supported = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio;
const content = node.metadata?.content?.trim();
const storageKey = node.metadata?.storageKey?.trim();
if (!supported || (!content && !storageKey)) return;
try {
const downloadName = buildCanvasMediaDownloadFileName(canvasTitle, node);
await downloadBrowserMedia({ storageKey, url: content, fileName: downloadName });
} catch (error) {
message.error(error instanceof Error ? error.message : "下载失败");
}
}, [canvasTitle, message]);
const saveNodeAsset = useCallback(async (node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text && node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) return message.error("当前节点类型不能保存为素材");