feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选
This commit is contained in:
@@ -20,6 +20,7 @@ export default function LogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const keyword = searchParams.get("filter") || "";
|
||||
const status = normalizeStatus(searchParams.get("status"));
|
||||
const recordType = searchParams.get("recordType") === "download" ? "download" : searchParams.get("recordType") === "all" ? "all" : "request";
|
||||
const page = positiveInt(searchParams.get("page"), 1);
|
||||
const pageSize = normalizePageSize(searchParams.get("pageSize"));
|
||||
const debouncedKeyword = useDebouncedValue(keyword);
|
||||
@@ -30,7 +31,7 @@ export default function LogsPage() {
|
||||
const [detailLogId, setDetailLogId] = useState<string | null>(null);
|
||||
const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const hasFilters = Boolean(keyword || status !== "all");
|
||||
const hasFilters = Boolean(keyword || status !== "all" || recordType !== "request");
|
||||
|
||||
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
@@ -45,7 +46,7 @@ export default function LogsPage() {
|
||||
useEffect(() => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
void listAdminApiLogs({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize })
|
||||
void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize })
|
||||
.then((result) => {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setLogs(result.logs);
|
||||
@@ -55,7 +56,7 @@ export default function LogsPage() {
|
||||
})
|
||||
.catch((error) => sequence === requestSequence.current && message.error(error instanceof Error ? error.message : "读取请求明细失败"))
|
||||
.finally(() => sequence === requestSequence.current && setLoading(false));
|
||||
}, [debouncedKeyword, status, page, pageSize]);
|
||||
}, [debouncedKeyword, status, recordType, page, pageSize]);
|
||||
|
||||
const columns: ColumnsType<ApiCallLog> = [
|
||||
{ title: "时间", width: 168, render: (_, log) => formatTime(log.startedAt || log.createdAt) },
|
||||
@@ -129,7 +130,7 @@ export default function LogsPage() {
|
||||
description="模型生成、状态查询与结果下载;仅计费调用扣除积分"
|
||||
actions={
|
||||
<AdminExportButton
|
||||
exportFile={() => exportAdminApiLogs({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
|
||||
exportFile={() => exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
|
||||
fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`}
|
||||
label="导出当前筛选"
|
||||
successMessage="已按当前筛选导出请求明细"
|
||||
@@ -155,6 +156,8 @@ export default function LogsPage() {
|
||||
</>
|
||||
}
|
||||
toolbarFilters={
|
||||
<>
|
||||
<Select aria-label="明细类型" className="w-32" value={recordType} onChange={(value) => updateUrl({ recordType: value, page: 1 })} options={[{ label: "仅请求", value: "request" }, { label: "仅下载", value: "download" }, { label: "全部明细", value: "all" }]} />
|
||||
<Select
|
||||
className="w-32"
|
||||
value={status}
|
||||
@@ -165,15 +168,16 @@ export default function LogsPage() {
|
||||
{ label: "失败", value: "failed" },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
toolbarActive={hasFilters}
|
||||
onReset={() => updateUrl({ filter: "", status: "all", page: 1 })}
|
||||
onReset={() => updateUrl({ filter: "", status: "all", recordType: "request", page: 1 })}
|
||||
batchActions={
|
||||
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
|
||||
<AdminExportButton
|
||||
type="primary"
|
||||
size="small"
|
||||
exportFile={() => exportAdminApiLogs({ ids: selectedIds })}
|
||||
exportFile={() => exportAdminApiLogs({ recordType, ids: selectedIds })}
|
||||
fileName={() => `请求明细-已选${selectedIds.length}条.csv`}
|
||||
label="导出已选"
|
||||
successMessage={`已导出选中的 ${selectedIds.length} 条请求明细`}
|
||||
|
||||
@@ -20,7 +20,6 @@ export type CanvasUploadStatus = {
|
||||
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
|
||||
export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploadStatus; theme: CanvasTheme }) {
|
||||
const progress = Math.round((Math.min(status.step, status.total) / Math.max(status.total, 1)) * 100);
|
||||
const accent = status.error ? theme.accent.danger : status.done ? "#22c55e" : theme.node.activeStroke;
|
||||
return (
|
||||
<motion.div
|
||||
@@ -67,7 +66,7 @@ export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploa
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">{progress}%</span>
|
||||
<span className="sr-only">处理步骤 {status.step}/{status.total}</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link2 } from "lucide-react";
|
||||
import { ConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
|
||||
import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import type { CanvasConnectionApproach } from "@/lib/canvas/canvas-connection-tilt";
|
||||
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { sortCanvasNodesByStackOrder, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
@@ -24,6 +25,7 @@ type CanvasProjectWorldLayersProps = {
|
||||
connectingParams: ConnectionHandle | null;
|
||||
mouseWorld: Position;
|
||||
connectionTargetNodeId: string | null;
|
||||
connectionApproach: CanvasConnectionApproach;
|
||||
nodeById: Map<string, CanvasNodeData>;
|
||||
visibleNodes: CanvasNodeData[];
|
||||
nodeStackOrder: CanvasNodeStackOrder;
|
||||
@@ -159,6 +161,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
|
||||
isRelated={props.relatedNodeIds.has(node.id)}
|
||||
isFocusRelated={props.activeNodeId === node.id}
|
||||
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}
|
||||
connectionApproach={props.connectionApproach?.nodeId === node.id ? props.connectionApproach.point : undefined}
|
||||
forceInputVisible={Boolean(props.batchConnectionPreview)}
|
||||
batchCount={props.batchChildCountById.get(node.id) || 0}
|
||||
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { nanoid } from "nanoid";
|
||||
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
|
||||
@@ -983,6 +984,7 @@ function InfiniteCanvasPage() {
|
||||
closeConnectionCreateMenu,
|
||||
connectionTargetAnchorRatio,
|
||||
connectionTargetNodeId,
|
||||
connectionApproach,
|
||||
connectingParams,
|
||||
createConnectedNode,
|
||||
getConnectionCreateDisabledReason,
|
||||
@@ -1460,6 +1462,7 @@ function InfiniteCanvasPage() {
|
||||
updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
|
||||
}, [updateNodeFromContent]);
|
||||
const canvasNodeActions = useMemo<CanvasNodeActionContextValue>(() => ({
|
||||
upload: replaceCanvasNodeMedia,
|
||||
download: downloadNodeImage,
|
||||
duplicate: duplicateNodeFromContent,
|
||||
deleteNode: deleteNodeFromContent,
|
||||
@@ -1468,7 +1471,7 @@ function InfiniteCanvasPage() {
|
||||
updateMediaNode: updateMediaNodeFromContent,
|
||||
openPortraitClearance,
|
||||
openArtCritique,
|
||||
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
|
||||
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
|
||||
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
|
||||
projectId,
|
||||
domainProjectId: currentProject?.projectId,
|
||||
@@ -2295,6 +2298,7 @@ function InfiniteCanvasPage() {
|
||||
<CanvasNodeActionContext.Provider value={canvasNodeActions}>
|
||||
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
|
||||
<CanvasProjectWorldLayers
|
||||
connectionApproach={connectionApproach}
|
||||
projectId={projectId}
|
||||
viewportScale={viewport.k}
|
||||
connectionLayerBounds={connectionLayerBounds}
|
||||
@@ -2497,7 +2501,7 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
|
||||
{dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
|
||||
<CanvasNodePanelOverlay
|
||||
node={dialogNode}
|
||||
viewport={viewport}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
|
||||
import { App } from "antd";
|
||||
import { nanoid } from "nanoid";
|
||||
import { latchCanvasConnectionApproach, type CanvasConnectionApproach } from "@/lib/canvas/canvas-connection-tilt";
|
||||
|
||||
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
@@ -86,6 +87,7 @@ export function useCanvasConnectionController({
|
||||
const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses);
|
||||
const [connectingParams, setConnectingParams] = useState<ConnectionHandle | null>(null);
|
||||
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState<string | null>(null);
|
||||
const [connectionApproach, setConnectionApproach] = useState<CanvasConnectionApproach>(null);
|
||||
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState<number | undefined>();
|
||||
const [pendingConnectionCreate, setPendingConnectionCreate] = useState<PendingConnectionCreate | null>(null);
|
||||
const [batchConnectionPreview, setBatchConnectionPreview] = useState<CanvasBatchConnectionPreview | null>(null);
|
||||
@@ -108,6 +110,7 @@ export function useCanvasConnectionController({
|
||||
const updateBatchConnectionPreview = useCallback((next: CanvasBatchConnectionPreview | null) => {
|
||||
batchConnectionPreviewRef.current = next;
|
||||
setBatchConnectionPreview(next);
|
||||
setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, next && next.status !== "invalid" ? next.targetNodeId : null, next?.mouseWorld || { x: 0, y: 0 }));
|
||||
}, []);
|
||||
|
||||
const clearBatchConnection = useCallback(() => {
|
||||
@@ -120,6 +123,7 @@ export function useCanvasConnectionController({
|
||||
connectingParamsRef.current = next;
|
||||
setConnectingParams(next);
|
||||
if (!next) {
|
||||
setConnectionApproach(null);
|
||||
connectingPointerIdRef.current = null;
|
||||
connectingPointerStartRef.current = null;
|
||||
setConnectionTargetNodeId(null);
|
||||
@@ -596,9 +600,11 @@ export function useCanvasConnectionController({
|
||||
const current = connectingParamsRef.current;
|
||||
if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return;
|
||||
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current);
|
||||
const point = screenToCanvas(event.clientX, event.clientY);
|
||||
setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, dropTarget.nodeId, point));
|
||||
setConnectionTargetNodeId(dropTarget.nodeId);
|
||||
setConnectionTargetAnchorRatio(dropTarget.anchorRatio);
|
||||
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
|
||||
setMouseWorld(point);
|
||||
};
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
// Pointer events can arrive faster than the canvas can paint. Keep
|
||||
@@ -677,6 +683,7 @@ export function useCanvasConnectionController({
|
||||
cancelPendingConnectionCreate,
|
||||
closeConnectionCreateMenu,
|
||||
connectionTargetNodeId,
|
||||
connectionApproach,
|
||||
connectionTargetAnchorRatio,
|
||||
connectingParams,
|
||||
createConnectedNode,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
|
||||
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import { createPortraitTextureNode } from "@/lib/canvas/canvas-image-source";
|
||||
import { captureVideoFrames } from "@/lib/canvas/canvas-video-frame";
|
||||
import { buildVideoFrameNodes } from "@/lib/canvas/canvas-video-frame-nodes";
|
||||
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
|
||||
@@ -182,14 +183,16 @@ export function useCanvasMediaTools({
|
||||
return;
|
||||
}
|
||||
const portraitTextureSettings = { ...DEFAULT_PORTRAIT_TEXTURE_SETTINGS, ...node.metadata?.portraitTexture };
|
||||
const composerContent = node.metadata?.composerContent?.trim() || node.metadata?.prompt?.trim() || "@图片1";
|
||||
const child = createPortraitTextureNode(node, nanoid());
|
||||
child.metadata = { ...child.metadata, portraitTexture: portraitTextureSettings };
|
||||
setHoveredNodeId(null);
|
||||
setToolbarNodeId(null);
|
||||
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, prompt: composerContent, composerContent, portraitTexture: portraitTextureSettings } } : item));
|
||||
setSelectedNodeIds(new Set([node.id]));
|
||||
setNodes((current) => [...current, child]);
|
||||
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: child.id }]);
|
||||
setSelectedNodeIds(new Set([child.id]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(node.id);
|
||||
}, [message, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
|
||||
setDialogNodeId(child.id);
|
||||
}, [message, setConnections, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
|
||||
|
||||
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
|
||||
if (!node.metadata?.content) return;
|
||||
|
||||
@@ -11,6 +11,8 @@ import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
|
||||
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
import { CANVAS_UPLOAD_ACCEPT, createFileUploadPlaceholder, uploadNodeType, uploadPercent } from "@/lib/canvas/canvas-file-upload";
|
||||
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||
import { getProjectUnit } from "@/services/api/projects";
|
||||
@@ -141,36 +143,83 @@ export function useCanvasUpload({
|
||||
return result.assetId;
|
||||
}, [canvasId, domainProjectId, queryClient]);
|
||||
|
||||
const createImageFileNode = useCallback(async (file: File, position: Position) => {
|
||||
const progress = startUploadStatus("上传图片", "读取图片文件", domainProjectId ? 4 : 3);
|
||||
const activeUploadsRef = useRef(new Set<string>());
|
||||
const createFileNode = useCallback(async (file: File, position: Position, replaceId?: string) => {
|
||||
const original = replaceId ? nodesRef.current.find((node) => node.id === replaceId) : undefined;
|
||||
if (replaceId && (!original || activeUploadsRef.current.has(replaceId))) return null;
|
||||
const id = replaceId || `upload-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
activeUploadsRef.current.add(id);
|
||||
const progress = startUploadStatus(replaceId ? "替换文件" : "上传文件", "读取文件信息", domainProjectId ? 4 : 3);
|
||||
try {
|
||||
progress.update("上传到服务器并同步资源", 2);
|
||||
const image = await uploadImage(file);
|
||||
progress.update("更新画布节点", 3);
|
||||
const size = fitNodeSize(image.width, image.height);
|
||||
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const node: CanvasNodeData = {
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title: file.name,
|
||||
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
metadata: imageMetadata(image),
|
||||
const placeholder = await createFileUploadPlaceholder(id, file, position);
|
||||
setNodes((current) => replaceId ? current.map((item) => item.id === id ? {
|
||||
...item, width: placeholder.width, height: placeholder.height,
|
||||
metadata: { ...item.metadata, fileUpload: "uploading", fileUploadProgress: undefined, status: undefined, size: undefined, errorDetails: undefined },
|
||||
} : item) : [...current, placeholder]);
|
||||
selectInsertedNode(id, "close");
|
||||
let lastPercent: number | undefined;
|
||||
const onProgress = (loaded: number, total: number) => {
|
||||
const percent = uploadPercent(loaded, total);
|
||||
if (percent === undefined || percent === lastPercent) return;
|
||||
lastPercent = percent;
|
||||
setNodes((current) => current.map((item) => item.id === id ? { ...item, metadata: { ...item.metadata, fileUploadProgress: percent } } : item));
|
||||
progress.update(percent === 100 ? "文件已传输,正在保存与处理" : `已上传 ${percent}%`, 2);
|
||||
};
|
||||
setNodes((current) => [...current, node]);
|
||||
selectInsertedNode(id, "open");
|
||||
progress.update("上传文件并同步资源", 2);
|
||||
let metadata: CanvasNodeData["metadata"];
|
||||
if (placeholder.type === CanvasNodeType.Image) {
|
||||
metadata = imageMetadata(await uploadImage(file, onProgress));
|
||||
} else if (placeholder.type === CanvasNodeType.Text) {
|
||||
const content = await file.text();
|
||||
const resource = await uploadResourceFile(file, "file", { fileName: file.name }, onProgress);
|
||||
metadata = { content, prompt: content, storageKey: resourceStorageKey(resource.id), mimeType: file.type || "text/plain", bytes: file.size, status: "success" };
|
||||
} else {
|
||||
const media = await uploadMediaFile(file, placeholder.type === CanvasNodeType.Video ? "video" : "audio", onProgress);
|
||||
metadata = placeholder.type === CanvasNodeType.Video ? videoMetadata(media) : audioMetadata(media);
|
||||
}
|
||||
progress.update("更新画布节点", 3);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === id);
|
||||
if (!currentNode) {
|
||||
progress.done("上传完成,画布占位已移除");
|
||||
return null;
|
||||
}
|
||||
const node: CanvasNodeData = {
|
||||
...currentNode, type: placeholder.type,
|
||||
metadata: {
|
||||
...currentNode.metadata, ...metadata,
|
||||
fileUpload: undefined, fileUploadProgress: undefined, errorDetails: undefined,
|
||||
...(replaceId ? {
|
||||
assetId: undefined, taskId: undefined, freeResize: false,
|
||||
isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined,
|
||||
batchFailedCount: undefined, batchUsesReferenceImages: undefined,
|
||||
generationType: undefined, generationResultPlacement: undefined,
|
||||
copiedFromNodeId: undefined, versionOfNodeId: undefined,
|
||||
model: undefined, size: undefined, quality: undefined,
|
||||
transparentBackground: undefined, count: undefined, references: undefined,
|
||||
primaryImageId: undefined, imageBatchExpanded: undefined,
|
||||
richText: undefined, composerContent: undefined,
|
||||
} : {}),
|
||||
},
|
||||
};
|
||||
setNodes((current) => current.map((item) => item.id === id ? { ...item, type: node.type, metadata: node.metadata } : item));
|
||||
if (domainProjectId) progress.update("写入项目资产", 4);
|
||||
const persisted = await persistMediaNode(node);
|
||||
progress.done(persisted ? "图片已添加到画布" : "图片已添加,项目资产待重试");
|
||||
const localOnly = metadata.storageKey && !resourceIdFromStorageKey(metadata.storageKey);
|
||||
progress.done(localOnly ? "已保存在本机,尚未上传到服务器" : persisted ? "文件已添加到画布" : "文件已添加,项目资产待重试");
|
||||
if (localOnly) message.warning("文件已保存在本机,尚未上传到服务器");
|
||||
return id;
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : "图片上传失败";
|
||||
const details = error instanceof Error ? error.message : "文件上传失败";
|
||||
setNodes((current) => current.map((item) => item.id !== id ? item : original?.metadata?.content ? {
|
||||
...item, width: original.width, height: original.height, metadata: original.metadata,
|
||||
} : { ...item, metadata: { ...item.metadata, fileUpload: "error", fileUploadProgress: undefined, errorDetails: details } }));
|
||||
progress.fail(details);
|
||||
message.error(details);
|
||||
return null;
|
||||
} finally {
|
||||
activeUploadsRef.current.delete(id);
|
||||
}
|
||||
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
|
||||
}, [domainProjectId, message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
|
||||
|
||||
const createImageAssetNode = useCallback(async (asset: ImageAsset, position?: Position) => {
|
||||
try {
|
||||
@@ -209,52 +258,6 @@ export function useCanvasUpload({
|
||||
}
|
||||
}, [getCanvasCenter, message, selectInsertedNode, setNodes]);
|
||||
|
||||
const createVideoFileNode = useCallback(async (file: File, position: Position) => {
|
||||
const progress = startUploadStatus("上传视频", "读取视频文件", domainProjectId ? 4 : 3);
|
||||
try {
|
||||
progress.update("上传到服务器并同步资源", 2);
|
||||
const video = await uploadMediaFile(file, "video");
|
||||
progress.update("更新画布节点", 3);
|
||||
const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
|
||||
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const node = { id, type: CanvasNodeType.Video, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: videoMetadata(video) } satisfies CanvasNodeData;
|
||||
setNodes((current) => [...current, node]);
|
||||
selectInsertedNode(id, "open");
|
||||
if (domainProjectId) progress.update("写入项目资产", 4);
|
||||
const persisted = await persistMediaNode(node);
|
||||
progress.done(persisted ? "视频已添加到画布" : "视频已添加,项目资产待重试");
|
||||
return id;
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : "视频上传失败";
|
||||
progress.fail(details);
|
||||
message.error(details);
|
||||
return null;
|
||||
}
|
||||
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
|
||||
|
||||
const createAudioFileNode = useCallback(async (file: File, position: Position) => {
|
||||
const progress = startUploadStatus("上传音频", "读取音频文件", domainProjectId ? 4 : 3);
|
||||
try {
|
||||
progress.update("上传到服务器并同步资源", 2);
|
||||
const audio = await uploadMediaFile(file, "audio");
|
||||
progress.update("更新画布节点", 3);
|
||||
const size = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
|
||||
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const node = { id, type: CanvasNodeType.Audio, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: audioMetadata(audio) } satisfies CanvasNodeData;
|
||||
setNodes((current) => [...current, node]);
|
||||
selectInsertedNode(id, "preserve");
|
||||
if (domainProjectId) progress.update("写入项目资产", 4);
|
||||
const persisted = await persistMediaNode(node);
|
||||
progress.done(persisted ? "音频已添加到画布" : "音频已添加,项目资产待重试");
|
||||
return id;
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : "音频上传失败";
|
||||
progress.fail(details);
|
||||
message.error(details);
|
||||
return null;
|
||||
}
|
||||
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
|
||||
|
||||
const createTextNodeFromClipboard = useCallback((text: string, position?: Position) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
@@ -312,8 +315,8 @@ export function useCanvasUpload({
|
||||
: target?.type === CanvasNodeType.Video
|
||||
? "video/*"
|
||||
: target?.type === CanvasNodeType.Audio
|
||||
? "audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"
|
||||
: "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav";
|
||||
? "audio/*,.mp3,.wav"
|
||||
: target?.type === CanvasNodeType.Text ? "text/plain,text/markdown,.txt,.md,.markdown" : CANVAS_UPLOAD_ACCEPT;
|
||||
}
|
||||
imageInputRef.current?.click();
|
||||
}, [nodesRef]);
|
||||
@@ -324,9 +327,9 @@ export function useCanvasUpload({
|
||||
}, []);
|
||||
|
||||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||||
const supportedFiles = files.filter((file) => file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file));
|
||||
const supportedFiles = files.filter((file) => uploadNodeType(file));
|
||||
if (!supportedFiles.length) {
|
||||
message.warning("请选择图片、视频、MP3 或 WAV 文件");
|
||||
message.warning("请选择图片、视频、音频或 TXT / Markdown 文件");
|
||||
return false;
|
||||
}
|
||||
const center = uploadTargetRef.current?.position || getCanvasCenter();
|
||||
@@ -339,11 +342,7 @@ export function useCanvasUpload({
|
||||
x: originX + (index % columns) * BATCH_UPLOAD_COLUMN_GAP,
|
||||
y: center.y + Math.floor(index / columns) * BATCH_UPLOAD_ROW_GAP,
|
||||
};
|
||||
const createdId = await (isAudioFile(file)
|
||||
? createAudioFileNode(file, position)
|
||||
: file.type.startsWith("video/")
|
||||
? createVideoFileNode(file, position)
|
||||
: createImageFileNode(file, position));
|
||||
const createdId = await createFileNode(file, position);
|
||||
if (createdId) createdIds.push(createdId);
|
||||
}
|
||||
if (!createdIds.length) return false;
|
||||
@@ -354,7 +353,7 @@ export function useCanvasUpload({
|
||||
if (failedCount) message.warning(`已添加 ${createdIds.length} 个文件,${failedCount} 个上传失败`);
|
||||
else message.success(`已添加 ${createdIds.length} 个文件到画布`);
|
||||
return true;
|
||||
}, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
}, [createFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
// 时间线专用:把本地音视频文件上传为直连媒体(仅时间线作用域,不创建画布节点),返回媒体描述数组。
|
||||
const uploadTimelineMedia = useCallback(async (files: File[]): Promise<TimelineDirectMedia[]> => {
|
||||
@@ -441,89 +440,11 @@ export function useCanvasUpload({
|
||||
|
||||
const replaceNodeMedia = useCallback(async (nodeId: string, file: File) => {
|
||||
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
|
||||
if (!currentNode) return false;
|
||||
if (isAudioFile(file)) {
|
||||
const progress = startUploadStatus("替换音频", "读取音频文件");
|
||||
try {
|
||||
progress.update("上传到服务器并同步资源", 2);
|
||||
const audio = await uploadMediaFile(file, "audio");
|
||||
progress.update("更新画布节点", 3);
|
||||
const node = { ...currentNode, type: CanvasNodeType.Audio, title: file.name, metadata: { ...currentNode.metadata, ...audioMetadata(audio), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
|
||||
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
|
||||
selectInsertedNode(nodeId, "preserve");
|
||||
const persisted = await persistMediaNode(node);
|
||||
progress.done(persisted ? "音频已替换,可撤销恢复" : "音频已替换,项目资产待重试");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : "音频替换失败";
|
||||
progress.fail(details);
|
||||
message.error(details);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (file.type.startsWith("video/")) {
|
||||
const progress = startUploadStatus("替换视频", "读取视频文件");
|
||||
try {
|
||||
progress.update("上传到服务器并同步资源", 2);
|
||||
const video = await uploadMediaFile(file, "video");
|
||||
progress.update("更新画布节点", 3);
|
||||
const node = { ...currentNode, type: CanvasNodeType.Video, title: file.name, metadata: { ...currentNode.metadata, ...videoMetadata(video), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
|
||||
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
|
||||
selectInsertedNode(nodeId, "open");
|
||||
const persisted = await persistMediaNode(node);
|
||||
progress.done(persisted ? "视频已替换,可撤销恢复" : "视频已替换,项目资产待重试");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : "视频替换失败";
|
||||
progress.fail(details);
|
||||
message.error(details);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const progress = startUploadStatus("替换图片", "读取图片文件");
|
||||
try {
|
||||
progress.update("上传到服务器并同步资源", 2);
|
||||
const image = await uploadImage(file);
|
||||
progress.update("更新画布节点", 3);
|
||||
const node = {
|
||||
...currentNode,
|
||||
type: CanvasNodeType.Image,
|
||||
title: file.name,
|
||||
metadata: {
|
||||
...currentNode.metadata,
|
||||
...imageMetadata(image),
|
||||
assetId: undefined,
|
||||
taskId: undefined,
|
||||
errorDetails: undefined,
|
||||
freeResize: false,
|
||||
isBatchRoot: undefined,
|
||||
batchRootId: undefined,
|
||||
batchChildIds: undefined,
|
||||
batchFailedCount: undefined,
|
||||
batchUsesReferenceImages: undefined,
|
||||
generationType: undefined,
|
||||
model: undefined,
|
||||
size: undefined,
|
||||
quality: undefined,
|
||||
transparentBackground: undefined,
|
||||
count: undefined,
|
||||
references: undefined,
|
||||
primaryImageId: undefined,
|
||||
imageBatchExpanded: undefined,
|
||||
},
|
||||
} satisfies CanvasNodeData;
|
||||
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
|
||||
selectInsertedNode(nodeId, "open");
|
||||
const persisted = await persistMediaNode(node);
|
||||
progress.done(persisted ? "图片已替换,可撤销恢复" : "图片已替换,项目资产待重试");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : "图片替换失败";
|
||||
progress.fail(details);
|
||||
message.error(details);
|
||||
return false;
|
||||
}
|
||||
}, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
|
||||
if (!currentNode || !uploadNodeType(file)) return false;
|
||||
const typedNode = [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(currentNode.type as CanvasNodeType);
|
||||
if (typedNode && uploadNodeType(file) !== currentNode.type) return false;
|
||||
return Boolean(await createFileNode(file, currentNode.position, nodeId));
|
||||
}, [createFileNode, nodesRef]);
|
||||
|
||||
const pasteSystemClipboard = useCallback(async (position?: Position, clipboardEvent?: ClipboardEvent | null) => {
|
||||
const isNodeMarker = (value: string) => {
|
||||
@@ -536,7 +457,7 @@ export function useCanvasUpload({
|
||||
if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复");
|
||||
return true;
|
||||
}
|
||||
const inserted = await createImageFileNode(file, position || getCanvasCenter());
|
||||
const inserted = await createFileNode(file, position || getCanvasCenter());
|
||||
if (inserted) message.success("已从剪切板添加图片");
|
||||
return Boolean(inserted);
|
||||
};
|
||||
@@ -587,20 +508,17 @@ export function useCanvasUpload({
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
|
||||
}, [createFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
|
||||
|
||||
const handleImageInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
const target = uploadTargetRef.current;
|
||||
try {
|
||||
if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return;
|
||||
if (!file || !uploadNodeType(file)) return;
|
||||
if (target?.nodeId) {
|
||||
const targetNode = nodesRef.current.find((node) => node.id === target.nodeId);
|
||||
const compatible = !targetNode
|
||||
|| (targetNode.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|
||||
|| (targetNode.type === CanvasNodeType.Video && file.type.startsWith("video/"))
|
||||
|| (targetNode.type === CanvasNodeType.Audio && isAudioFile(file))
|
||||
|| (targetNode.type !== CanvasNodeType.Image && targetNode.type !== CanvasNodeType.Video && targetNode.type !== CanvasNodeType.Audio);
|
||||
const compatible = targetNode && (targetNode.type === uploadNodeType(file)
|
||||
|| ![CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(targetNode.type as CanvasNodeType));
|
||||
if (!compatible) {
|
||||
message.warning("请选择与当前节点相同类型的媒体文件");
|
||||
return;
|
||||
@@ -609,12 +527,12 @@ export function useCanvasUpload({
|
||||
return;
|
||||
}
|
||||
const position = target?.position || getCanvasCenter();
|
||||
await (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
|
||||
await createFileNode(file, position);
|
||||
} finally {
|
||||
uploadTargetRef.current = null;
|
||||
event.target.value = "";
|
||||
}
|
||||
}, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
|
||||
}, [createFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
|
||||
|
||||
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -635,7 +553,7 @@ export function useCanvasUpload({
|
||||
void createImageAssetNode(asset, screenToCanvas(event.clientX, event.clientY));
|
||||
return;
|
||||
}
|
||||
const files = Array.from(event.dataTransfer.files).filter((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
|
||||
const files = Array.from(event.dataTransfer.files).filter((item) => uploadNodeType(item));
|
||||
if (!files.length) return;
|
||||
if (files.length > 1) {
|
||||
void handleUploadFiles(files);
|
||||
@@ -644,9 +562,7 @@ export function useCanvasUpload({
|
||||
const file = files[0];
|
||||
const position = screenToCanvas(event.clientX, event.clientY);
|
||||
const target = [...nodesRef.current].reverse().find((node) => {
|
||||
const compatible = (node.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|
||||
|| (node.type === CanvasNodeType.Video && file.type.startsWith("video/"))
|
||||
|| (node.type === CanvasNodeType.Audio && isAudioFile(file));
|
||||
const compatible = node.type === uploadNodeType(file);
|
||||
return compatible && position.x >= node.position.x && position.x <= node.position.x + node.width && position.y >= node.position.y && position.y <= node.position.y + node.height;
|
||||
});
|
||||
if (target) {
|
||||
@@ -655,8 +571,8 @@ export function useCanvasUpload({
|
||||
});
|
||||
return;
|
||||
}
|
||||
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
|
||||
}, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
|
||||
void createFileNode(file, position);
|
||||
}, [createFileNode, createImageAssetNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
|
||||
|
||||
const handleFileDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (!hasDraggedFiles(event)) return;
|
||||
@@ -678,10 +594,10 @@ export function useCanvasUpload({
|
||||
}, []);
|
||||
|
||||
const pasteAssistantImage = useCallback((file: File) => {
|
||||
void createImageFileNode(file, getCanvasCenter()).then((inserted) => {
|
||||
void createFileNode(file, getCanvasCenter()).then((inserted) => {
|
||||
if (inserted) message.success("已从剪切板添加图片");
|
||||
});
|
||||
}, [createImageFileNode, getCanvasCenter, message]);
|
||||
}, [createFileNode, getCanvasCenter, message]);
|
||||
|
||||
const openAssetsAtPosition = useCallback((position?: Position) => {
|
||||
assetInsertPositionRef.current = position || null;
|
||||
|
||||
@@ -157,10 +157,11 @@ export default function PluginsPage() {
|
||||
}, [backendPluginById, categoryFilter, features.systemPluginsVisibleToUsers, installations, pluginStates, registeredPlugins, search, statusFilter, trustFilter, user?.role]);
|
||||
|
||||
const pluginSections = useMemo(
|
||||
() => [
|
||||
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, section.key)) })),
|
||||
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
|
||||
].filter((section) => categoryFilter === "all" || section.key === categoryFilter),
|
||||
() =>
|
||||
[
|
||||
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, section.key)) })),
|
||||
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
|
||||
].filter((section) => categoryFilter === "all" || section.key === categoryFilter),
|
||||
[categoryFilter, filteredPlugins],
|
||||
);
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ import { SkillRuntimePicker, useSkillRuntimeCatalog } from "@/components/skills/
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
|
||||
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import {
|
||||
@@ -73,7 +72,7 @@ import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@
|
||||
|
||||
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
|
||||
import { chapterStoryboardAssets, chapterStoryboardCharacters, chapterStoryboardReplaceImpact, storyboardRowsToProjectShots } from "./chapter-storyboard-production";
|
||||
import { chapterCharactersFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai";
|
||||
import { chapterAssetsFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterAssets, generateChapterStoryboard } from "./project-chapter-ai";
|
||||
|
||||
const CHAPTER_ROW_HEIGHT = 62;
|
||||
const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
|
||||
@@ -349,14 +348,15 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
const markChapterOperationCompleted = (unitId: string, kind: ChapterOperationKind) => {
|
||||
setCompletedChapterOperations((current) => ({ ...current, [chapterOperationKey(unitId, kind)]: true }));
|
||||
};
|
||||
const storeExtractedCharacters = async (unitId: string, characters: Awaited<ReturnType<typeof extractChapterCharacters>>) => {
|
||||
const knownNames = new Set([
|
||||
...detail.assetCandidates.filter((candidate) => candidate.category === "character").map((candidate) => normalizeCharacterName(candidate.name)),
|
||||
...detail.assets.filter((asset) => asset.category === "character").map((asset) => normalizeCharacterName(asset.title)),
|
||||
]);
|
||||
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
|
||||
const created = fresh.length
|
||||
? await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })), "chapter_character_extract")
|
||||
const storeExtractedAssets = async (unitId: string, assets: Awaited<ReturnType<typeof extractChapterAssets>>) => {
|
||||
// 去重由服务端按分类、名称及别名统一完成,避免使用页面分页快照漏判。
|
||||
const candidates = [
|
||||
...assets.characters.map((asset) => ({ unitId, name: asset.name, category: "character" as const, details: { ...asset } })),
|
||||
...assets.scenes.map((asset) => ({ unitId, name: asset.name, category: "environment" as const, details: { ...asset } })),
|
||||
...assets.props.map((asset) => ({ unitId, name: asset.name, category: "prop" as const, details: { ...asset } })),
|
||||
];
|
||||
const created = candidates.length
|
||||
? await createProjectAssetCandidates(detail.project.id, candidates, "chapter_character_extract")
|
||||
: { candidates: [] };
|
||||
markChapterOperationCompleted(unitId, "characters");
|
||||
refreshProject();
|
||||
@@ -380,16 +380,16 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
operationUnitId = input.chapterId;
|
||||
setCharacterExtractOpen(false);
|
||||
beginChapterOperation(operationUnitId, "characters");
|
||||
message.info("角色提取任务已开始,可继续编辑或切换章节");
|
||||
const characters = await extractChapterCharacters(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
|
||||
const freshCount = await storeExtractedCharacters(operationUnitId, characters);
|
||||
message.info("角色、场景与道具提取任务已开始,可继续编辑或切换章节");
|
||||
const assets = await extractChapterAssets(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
|
||||
const freshCount = await storeExtractedAssets(operationUnitId, assets);
|
||||
if (!freshCount) {
|
||||
message.info("本章角色已存在于待确认列表中");
|
||||
message.info("没有新增资产:提取为空或资产已存在");
|
||||
return;
|
||||
}
|
||||
message.success(`已提取 ${freshCount} 个角色,请到项目资产确认`);
|
||||
message.success(`已提取 ${freshCount} 个角色、场景或道具,请到项目资产确认`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "角色提取失败");
|
||||
message.error(error instanceof Error ? error.message : "章节资产提取失败");
|
||||
} finally {
|
||||
if (operationUnitId) finishChapterOperation(operationUnitId, "characters");
|
||||
}
|
||||
@@ -477,8 +477,8 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
recoveringTaskIdsRef.current.add(task.id);
|
||||
void queryGenerationTask(task.id).then(async (completedTask) => {
|
||||
if (kind === "characters") {
|
||||
await storeExtractedCharacters(taskChapterId, chapterCharactersFromGenerationTask(completedTask));
|
||||
message.success("已恢复刷新前完成的角色提取结果");
|
||||
await storeExtractedAssets(taskChapterId, chapterAssetsFromGenerationTask(completedTask));
|
||||
message.success("已恢复刷新前完成的章节资产提取结果");
|
||||
} else {
|
||||
await storeGeneratedStoryboard(taskChapterId, chapterStoryboardFromGenerationTask(completedTask).rows);
|
||||
message.success("已恢复刷新前完成的章节分镜");
|
||||
@@ -599,7 +599,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
|
||||
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色"}>{characterOperation ? `提取角色(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取角色(已生成)" : "提取角色"}</Button>
|
||||
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色、场景与道具,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色、场景与道具"}>{characterOperation ? `提取资产(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取资产(已生成)" : "提取角色、场景与道具"}</Button>
|
||||
<Button size="small" type="primary" icon={<Clapperboard className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(storyboardOperation)} loading={Boolean(storyboardOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setSelectedSkillIds([]); setStoryboardOpen(true); }} aria-label={storyboardOperation ? `生成到分镜制作,已运行 ${formatOperationElapsed(storyboardOperation.startedAt, operationNow)}` : "生成到分镜制作"}>{storyboardOperation ? `生成到分镜制作(已运行${formatOperationElapsed(storyboardOperation.startedAt, operationNow)})` : storyboardGenerated ? "生成到分镜制作(已生成)" : "生成到分镜制作"}</Button>
|
||||
<Button size="small" type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!selectedUnit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
|
||||
</div>
|
||||
@@ -613,7 +613,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
</section>
|
||||
<CreateChapterModal open={createOpen} onClose={() => setCreateOpen(false)} loading={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
|
||||
<ImportNovelModal open={importOpen} loading={importMutation.isPending} onClose={() => setImportOpen(false)} onImport={(chapters) => importMutation.mutate(chapters)} />
|
||||
<Modal title="提取章节角色" open={characterExtractOpen} width={500} okText="开始提取" cancelText="取消" okButtonProps={{ disabled: !selectedTextModel }} onCancel={() => setCharacterExtractOpen(false)} onOk={() => void extractCharacters()} styles={{ body: { paddingTop: 12 } }}>
|
||||
<Modal title="提取章节角色、场景与道具" open={characterExtractOpen} width={500} okText="开始提取" cancelText="取消" okButtonProps={{ disabled: !selectedTextModel }} onCancel={() => setCharacterExtractOpen(false)} onOk={() => void extractCharacters()} styles={{ body: { paddingTop: 12 } }}>
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-lg border border-border/70 bg-foreground/[.018] px-3 py-2.5">
|
||||
<div className="text-[var(--fs-tiny)] text-foreground/42">当前章节</div>
|
||||
@@ -622,7 +622,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-foreground/68">文本模型</span>
|
||||
<ModelPicker config={effectiveConfig} capability="text" value={selectedTextModel} onChange={setSelectedTextModel} fullWidth placeholder="选择用于提取角色的文本模型" showSelectedPrice={false} onMissingConfig={() => navigateToSettings({ continueCreation: true })} />
|
||||
<ModelPicker config={effectiveConfig} capability="text" value={selectedTextModel} onChange={setSelectedTextModel} fullWidth placeholder="选择用于提取角色、场景与道具的文本模型" showSelectedPrice={false} onMissingConfig={() => navigateToSettings({ continueCreation: true })} />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
|
||||
import { storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
|
||||
import { parseCharacterBreakdown, type CharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
|
||||
import { parseChapterAssetBreakdown, type ChapterAssetBreakdown } from "@/lib/canvas/chapter-asset-breakdown";
|
||||
import { parseCharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
|
||||
import { backendProviderConfig, parseBackendGenerationResult } from "@/services/api/generation-task";
|
||||
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
@@ -38,24 +39,28 @@ export function chapterTaskIdentity(task: GenerationTask): { chapterId: string;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function chapterCharactersFromGenerationTask(task: GenerationTask) {
|
||||
export function chapterAssetsFromGenerationTask(task: GenerationTask) {
|
||||
const result = parseBackendGenerationResult(task);
|
||||
if (!result.text?.trim()) throw new Error("模型没有返回可用的角色提取结果");
|
||||
return parseCharacterBreakdown(result.text);
|
||||
if (!result.text?.trim()) throw new Error("模型没有返回可用的章节资产提取结果");
|
||||
// 历史角色任务的输出契约只有角色;仅按明确的模板操作恢复,不能给新任务缺字段兜底。
|
||||
if (generationTaskMetadata(task.inputJson).promptTemplateOperation === "character_extract") {
|
||||
return { characters: parseCharacterBreakdown(result.text), scenes: [], props: [] };
|
||||
}
|
||||
return parseChapterAssetBreakdown(result.text);
|
||||
}
|
||||
|
||||
export function chapterStoryboardFromGenerationTask(task: GenerationTask) {
|
||||
return storyboardRowsFromTask(task);
|
||||
}
|
||||
|
||||
export async function extractChapterCharacters(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise<CharacterBreakdown[]> {
|
||||
export async function extractChapterAssets(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise<ChapterAssetBreakdown> {
|
||||
const result = await runProjectTextTask(input, "chapter_character_breakdown", {
|
||||
项目名称: input.projectName,
|
||||
章节名称: input.chapterTitle,
|
||||
项目画风: input.projectStyle || "项目尚未指定画风,保持视觉描述中性、可执行。",
|
||||
章节正文: input.sourceText,
|
||||
}, options);
|
||||
return parseCharacterBreakdown(result);
|
||||
return parseChapterAssetBreakdown(result);
|
||||
}
|
||||
|
||||
type ChapterStoryboardGenerationInput = {
|
||||
@@ -118,9 +123,9 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string
|
||||
projectId: input.projectId,
|
||||
nodeId: `${operation}:${input.chapterId}`,
|
||||
mode: "text",
|
||||
prompt: "使用当前启用的角色卡提取模板。",
|
||||
prompt: "使用当前启用的章节角色、场景与道具提取模板。",
|
||||
config: { ...input.config, model },
|
||||
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "character_extract", promptTemplateVariables },
|
||||
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "chapter_assets_extract", promptTemplateVariables },
|
||||
onTaskCreated: options?.onTaskUpdate,
|
||||
});
|
||||
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");
|
||||
|
||||
@@ -251,7 +251,6 @@ export default function TasksPage() {
|
||||
const [detail, logs] = await Promise.all([queryGenerationTask(task.id), listTaskLogs(task.id)]);
|
||||
setDetailTask(detail);
|
||||
setTaskLogs(logs);
|
||||
if (await syncGenerationTaskToCanvasStore(detail)) message.success("已同步到画布");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "任务详情加载失败");
|
||||
} finally {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/g
|
||||
import { statusLabel } from "@/lib/generation-task-display";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
|
||||
import { TaskVideoThumbnail } from "./task-video-thumbnail";
|
||||
|
||||
export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void }) {
|
||||
const isActive = task.status === "queued" || task.status === "running";
|
||||
@@ -22,7 +23,7 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
|
||||
{thumbnailUrl ? (
|
||||
<MediaPreview src={thumbnailUrl} kind="image" loading="lazy" className="h-full w-full object-cover" />
|
||||
) : isVideo && task.previewUrl ? (
|
||||
<span className="task-video-poster-placeholder"><Video /><small>视频预览</small></span>
|
||||
<TaskVideoThumbnail src={task.previewUrl} />
|
||||
) : (
|
||||
<Icon />
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
|
||||
import { TaskVideoThumbnail } from "./task-video-thumbnail";
|
||||
|
||||
export function TaskListRow({
|
||||
task,
|
||||
@@ -122,7 +123,7 @@ function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen:
|
||||
aria-label={previewUnavailable ? "预览不可用,素材可能已删除" : isVideo ? "放大预览生成视频" : "放大预览生成图片"}
|
||||
title={previewUnavailable ? "预览不可用,素材可能已删除" : undefined}
|
||||
>
|
||||
{thumbnailUrl ? <MediaPreview src={thumbnailUrl} kind="image" width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(thumbnailUrl)} /> : <span className="task-video-poster-placeholder"><Video className="size-4" /><small>视频</small></span>}
|
||||
{thumbnailUrl ? <MediaPreview src={thumbnailUrl} kind="image" width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(thumbnailUrl)} /> : isVideo ? <TaskVideoThumbnail src={task.previewUrl} /> : <ImageIcon className="size-4" />}
|
||||
{!previewUnavailable ? (
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition-[background-color,opacity] duration-150 group-hover:bg-black/30 group-hover:opacity-100 group-focus-visible:bg-black/30 group-focus-visible:opacity-100">
|
||||
{isVideo ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}
|
||||
|
||||
41
web/src/pages/tasks/task-video-thumbnail.tsx
Normal file
41
web/src/pages/tasks/task-video-thumbnail.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Video } from "lucide-react";
|
||||
import { MediaPreview } from "@/components/media-preview";
|
||||
import { captureVideoPoster } from "@/lib/video-poster";
|
||||
|
||||
/** 只解码进入视口的视频,复用串行解码队列;缩略图随组件释放,不写入用户资产。 */
|
||||
export function TaskVideoThumbnail({ src }: { src: string }) {
|
||||
const container = useRef<HTMLSpanElement>(null);
|
||||
const [poster, setPoster] = useState<{ source: string; url: string }>();
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let objectUrl = "";
|
||||
let started = false;
|
||||
const capture = () => {
|
||||
if (started || controller.signal.aborted) return;
|
||||
started = true;
|
||||
void captureVideoPoster(src, { signal: controller.signal, maxWidth: 400 }).then((result) => {
|
||||
if (controller.signal.aborted || !result.poster) return;
|
||||
objectUrl = URL.createObjectURL(result.poster);
|
||||
setPoster({ source: src, url: objectUrl });
|
||||
}).catch((error) => {
|
||||
if (!controller.signal.aborted) console.warn("任务视频首帧提取失败", error instanceof Error ? error.name : "UnknownError");
|
||||
});
|
||||
};
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
observer.disconnect();
|
||||
capture();
|
||||
}
|
||||
});
|
||||
if (container.current) observer.observe(container.current);
|
||||
return () => {
|
||||
controller.abort();
|
||||
observer.disconnect();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [src]);
|
||||
return <span ref={container} className="block h-full w-full">
|
||||
{poster?.source === src ? <MediaPreview src={poster.url} kind="image" className="h-full w-full object-cover" /> : <span className="task-video-poster-placeholder"><Video /><small>视频预览</small></span>}
|
||||
</span>;
|
||||
}
|
||||
Reference in New Issue
Block a user