Merge remote-tracking branch 'origin/main'

This commit is contained in:
ddCat
2026-08-10 00:28:12 +08:00
24 changed files with 1323 additions and 24 deletions

View File

@@ -1,4 +1,4 @@
import { Copy, Download, FileUp, MoreHorizontal, PencilLine, Plus, Search, Trash2, Upload } from "lucide-react";
import { CheckCheck, Copy, Download, FileUp, MoreHorizontal, PencilLine, Plus, Search, Trash2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Select, Space, Tag, Typography } from "antd";
@@ -95,6 +95,8 @@ export default function AssetsPage() {
return assetSearchText(asset).includes(query);
});
}, [validAssets, keyword, kindFilter, categoryFilter]);
const filteredAssetIds = useMemo(() => filteredAssets.map((asset) => asset.id), [filteredAssets]);
const allFilteredSelected = filteredAssetIds.length > 0 && filteredAssetIds.every((id) => selectedIds.includes(id));
const visibleAssets = useMemo(() => {
const start = (page - 1) * pageSize;
@@ -283,6 +285,7 @@ export default function AssetsPage() {
{selectedAssets.length ? (
<div className="mt-3 flex min-h-10 flex-wrap items-center gap-2 border-y border-border/75 py-2 text-xs">
<strong className="mr-auto font-medium">已选择 {selectedAssets.length} 个素材</strong>
<Button size="small" icon={<CheckCheck className="size-3.5" />} disabled={allFilteredSelected} onClick={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))}>全选</Button>
<Button size="small" onClick={() => setSelectedIds([])}>取消选择</Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => void exportSelectedAssets()}>导出</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => setBatchDeleteOpen(true)}>删除</Button>

View File

@@ -38,6 +38,8 @@ export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count,
canUndo: false,
canRedo: false,
extractingVideoFrame: false,
extractingAudio: false,
trimmingVideo: false,
mergingVideos,
addPanelOpen: false,
appearancePanelOpen: false,

View File

@@ -31,6 +31,7 @@ import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-pick
import { createStyleProfileSnapshot, resolveStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
import { CanvasSubtitleDialog } from "@/components/canvas/canvas-subtitle-dialog";
import { CanvasVideoSegmentDialog } from "@/components/canvas/canvas-video-segment-dialog";
import { CanvasTimelineDialog } from "@/components/canvas/canvas-timeline-dialog";
import { syncNodeSubtitlesToTimeline } from "@/lib/timeline/timeline-build";
import type { TimelineDirectMedia } from "@/types/timeline";
@@ -583,16 +584,23 @@ function InfiniteCanvasPage() {
generatePortraitTextureNode,
cropImageNode,
cropNodeId,
closeSegmentDialog,
extractAudioFromVideo,
extractVideoLastFrame,
extractingVideoFrameNodeId,
generateAngleNode,
generateEmotionNode,
handleSegmentConfirm,
maskEditImageNode,
maskEditNodeId,
mergeSelectedVideos,
mergeVideosByIds,
mergeVideoProgress,
saveAnnotatedImageNode,
segmentDialogMode,
segmentDialogNodeId,
segmentRunningMode,
setSegmentDialogNodeId,
setAngleNodeId,
setEmotionNodeId,
setAnnotationNodeId,
@@ -602,10 +610,12 @@ function InfiniteCanvasPage() {
setUpscaleNodeId,
splitImageNode,
splitNodeId,
trimVideoAndRegenerate,
upscaleImageNode,
upscaleNodeId,
} = useCanvasMediaTools({
projectId,
domainProjectId: linkedProjectId,
nodesRef,
connectionsRef,
selectedNodeIdsRef,
@@ -622,6 +632,7 @@ function InfiniteCanvasPage() {
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
onGenerateVideoNode: (nodeId, mode, prompt) => generateNodeRef.current?.(nodeId, mode, prompt),
});
const handleNodesDeleted = useCallback(
@@ -635,6 +646,7 @@ function InfiniteCanvasPage() {
setDrawingNodeId(clearDeletedId);
setInfoNodeId(clearDeletedId);
setSubtitleNodeId(clearDeletedId);
setSegmentDialogNodeId(clearDeletedId);
setCropNodeId(clearDeletedId);
setMaskEditNodeId(clearDeletedId);
setAnnotationNodeId(clearDeletedId);
@@ -656,7 +668,7 @@ function InfiniteCanvasPage() {
}
cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions });
},
[chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setCropNodeId, setEmotionNodeId, setMaskEditNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
[chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setCropNodeId, setEmotionNodeId, setMaskEditNodeId, setSegmentDialogNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
);
const {
@@ -875,6 +887,7 @@ function InfiniteCanvasPage() {
const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null;
const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null;
const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null;
const segmentNode = segmentDialogNodeId ? nodeById.get(segmentDialogNodeId) || null : null;
const textEditorNode = textEditorNodeId ? nodeById.get(textEditorNodeId) || null : null;
const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null;
const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null;
@@ -1840,9 +1853,13 @@ function InfiniteCanvasPage() {
}}
onViewImage={(node) => setPreviewNodeId(node.id)}
onExtractVideoLastFrame={(node) => void extractVideoLastFrame(node)}
onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}
onTrimVideoRegenerate={(node) => void trimVideoAndRegenerate(node)}
onSubtitles={(node) => setSubtitleNodeId(node.id)}
onTimeline={(node) => setTimelineNodeId(node.id)}
extractingVideoFrame={toolbarNode?.id === extractingVideoFrameNodeId}
extractingAudio={segmentRunningMode === "audio"}
trimmingVideo={segmentRunningMode === "video"}
onReversePrompt={createImageReversePromptNodes}
onRetry={(node) => void handleRetryNode(node)}
onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
@@ -1944,6 +1961,20 @@ function InfiniteCanvasPage() {
/>
) : null}
{segmentNode && segmentDialogMode ? (
<CanvasVideoSegmentDialog
node={segmentNode}
nodes={nodes}
connections={connections}
open={Boolean(segmentNode && segmentDialogMode)}
mode={segmentDialogMode}
config={effectiveConfig}
timeline={currentProject?.timeline || null}
onClose={closeSegmentDialog}
onConfirm={(params) => void handleSegmentConfirm(segmentNode, params)}
/>
) : null}
{timelineNode ? (
<CanvasTimelineDialog
node={timelineNode}

View File

@@ -256,7 +256,7 @@ export default function SharedCanvasPage() {
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onCancelTask={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
</InfiniteCanvas>
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} extractingVideoFrame={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoRegenerate={unauthorized} extractingVideoFrame={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onReset={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
<div className="pointer-events-none absolute bottom-5 right-5 z-[var(--z-panel-floating)] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>

View File

@@ -8,10 +8,12 @@ import type { CanvasImageSplitParams } from "@/components/canvas/canvas-node-spl
import type { CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
import type { CanvasVideoSegmentParams } from "@/components/canvas/canvas-video-segment-dialog";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
import { imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { buildAngleLabel, buildAnglePrompt, createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { validateVideoSegmentBatch } from "@/lib/canvas/canvas-video-regeneration";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import {
buildGenerationConfig,
@@ -25,11 +27,13 @@ import { compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canva
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS, buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { captureVideoLastFrame } from "@/lib/canvas/canvas-video-frame";
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
import { extractVideoAudio, trimVideoSegment } from "@/lib/canvas/canvas-video-segment";
import { generationErrorMessage } from "@/lib/generation-error";
import { navigateToSettings } from "@/lib/settings-navigation";
import { storeGeneratedVideo } from "@/services/api/video";
import { getMediaBlob } from "@/services/file-storage";
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import type { GenerationTask } from "@/services/api/task-center";
import { defaultConfig, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState } from "@/types/canvas";
@@ -37,6 +41,7 @@ import type { StartCanvasUploadStatus } from "./use-canvas-upload";
type UseCanvasMediaToolsOptions = {
projectId: string;
domainProjectId?: string;
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
selectedNodeIdsRef: { current: Set<string> };
@@ -53,6 +58,8 @@ type UseCanvasMediaToolsOptions = {
startGenerationRequest: (targetNodeId: string, originNodeId: string, runningId?: string, controller?: AbortController) => AbortController;
finishGenerationRequest: (targetNodeId: string, controller: AbortController) => void;
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => void;
/** 创建空视频节点后触发既有生成执行器(由页面层接线),用于“截取片段后调用视频模型重生成” */
onGenerateVideoNode?: (nodeId: string, mode: "video", prompt: string) => Promise<void> | void;
};
const NODE_STATUS_LOADING = "loading" as const;
@@ -67,6 +74,7 @@ const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用
export function useCanvasMediaTools({
projectId,
domainProjectId,
nodesRef,
connectionsRef,
selectedNodeIdsRef,
@@ -83,6 +91,7 @@ export function useCanvasMediaTools({
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
onGenerateVideoNode,
}: UseCanvasMediaToolsOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
@@ -98,6 +107,10 @@ export function useCanvasMediaTools({
const [emotionNodeId, setEmotionNodeId] = useState<string | null>(null);
const [extractingVideoFrameNodeId, setExtractingVideoFrameNodeId] = useState<string | null>(null);
const [mergeVideoProgress, setMergeVideoProgress] = useState<MergeVideoProgress | null>(null);
const [segmentDialogNodeId, setSegmentDialogNodeId] = useState<string | null>(null);
const [segmentDialogMode, setSegmentDialogMode] = useState<"audio" | "video" | null>(null);
const [segmentRunningMode, setSegmentRunningMode] = useState<"audio" | "video" | null>(null);
const segmentRunningRef = useRef(false);
const resolveImageEditStyle = useCallback((node: CanvasNodeData, prompt: string, config: AiConfig) => {
try {
@@ -259,6 +272,198 @@ export function useCanvasMediaTools({
}
}, [message, setConnections, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId, startUploadStatus]);
const extractAudioFromVideo = useCallback((node: CanvasNodeData) => {
if (!node.metadata?.content) {
message.warning("视频节点为空,无法提取声音");
return;
}
if (segmentRunningRef.current) return;
setHoveredNodeId(null);
setToolbarNodeId(null);
setSegmentDialogNodeId(node.id);
setSegmentDialogMode("audio");
}, [message, setHoveredNodeId, setToolbarNodeId]);
const trimVideoAndRegenerate = useCallback((node: CanvasNodeData) => {
if (!node.metadata?.content) {
message.warning("视频节点为空,无法截取片段");
return;
}
if (segmentRunningRef.current) return;
setHoveredNodeId(null);
setToolbarNodeId(null);
setSegmentDialogNodeId(node.id);
setSegmentDialogMode("video");
}, [message, setHoveredNodeId, setToolbarNodeId]);
const closeSegmentDialog = useCallback(() => {
if (segmentRunningRef.current) return;
setSegmentDialogNodeId(null);
setSegmentDialogMode(null);
}, []);
// 从视频片段提取声音:FFmpeg 提取 MP3 → 上传为音频资源 → 创建音频节点 → 写入素材库/项目资产。
const runExtractVideoAudio = useCallback(async (node: CanvasNodeData, params: CanvasVideoSegmentParams) => {
const progress = startUploadStatus("提取音频", "加载 FFmpeg", 4);
try {
const mp3 = await extractVideoAudio({ url: node.metadata?.content, storageKey: node.metadata?.storageKey }, { startMs: params.startMs, endMs: params.endMs }, node.metadata?.durationMs, (status) => {
progress.update(status.phase === "loading" ? "加载 FFmpeg" : status.phase === "reading" ? "读取视频资源" : "正在提取音频", status.phase === "encoding" ? 3 : 2);
});
progress.update("上传音频到服务器", 4);
const uploaded = await uploadMediaFile(mp3, "audio");
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const audioNode = createCanvasNode(
CanvasNodeType.Audio,
{ x: node.position.x + node.width + 96 + spec.width / 2, y: node.position.y + node.height / 2 },
{ ...audioMetadata(uploaded), prompt: `从「${node.title || "视频"}」提取的声音`, status: NODE_STATUS_SUCCESS },
);
audioNode.title = `声音 · ${node.title || "视频"}`;
const audioNodeId = audioNode.id;
setNodes((current) => [...current, audioNode]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: audioNodeId }]);
setSelectedNodeIds(new Set([audioNodeId]));
setSelectedConnectionId(null);
try {
const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: audioNode, source: "canvas-manual" });
setNodes((current) => current.map((item) => (item.id === audioNodeId ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)));
progress.done(result.linkedToProject ? "声音已提取并加入素材库与项目资产" : "声音已提取并加入素材库");
} catch (assetError) {
progress.done(`声音已提取并生成音频节点,素材库写入失败:${assetError instanceof Error ? assetError.message : "未知错误"}`);
}
} catch (error) {
const details = error instanceof Error ? error.message : "音频提取失败";
progress.fail(details);
message.error(details);
}
}, [domainProjectId, message, projectId, setConnections, setSelectedConnectionId, setSelectedNodeIds, setNodes, startUploadStatus]);
// 按段截取视频:FFmpeg 批量截取 → 每段创建片段节点与空结果节点 → 调用视频模型逐段重生成。
const runTrimVideoAndRegenerate = useCallback(async (node: CanvasNodeData, params: CanvasVideoSegmentParams) => {
const segments = params.segments || [];
if (!segments.length) {
message.warning("请至少添加一个截取片段");
return;
}
const generationConfig = buildGenerationConfig(effectiveConfig, node, "video");
const selectedConfig = { ...generationConfig, model: params.model || generationConfig.model };
const batchError = validateVideoSegmentBatch(selectedConfig, segments, params.operation);
if (batchError) {
message.warning(batchError);
return;
}
const progress = startUploadStatus("截取视频片段", "加载 FFmpeg", segments.length * 4);
try {
const prepared: Array<{ segmentNode: CanvasNodeData; targetNode: CanvasNodeData }> = [];
const failedSegments: string[] = [];
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const baseX = node.position.x + node.width + 96;
const baseY = node.position.y;
const effectivePrompt = (params.prompt || "保持画面主体与镜头,重新生成这一段视频").trim();
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
try {
const sourceNode = segment.sourceNodeId ? nodesRef.current.find((item) => item.id === segment.sourceNodeId) : undefined;
const trimSource = sourceNode
? { url: sourceNode.metadata?.content, storageKey: sourceNode.metadata?.storageKey }
: segment.sourceStorageKey || segment.sourceUrl
? { url: segment.sourceUrl, storageKey: segment.sourceStorageKey }
: { url: node.metadata?.content, storageKey: node.metadata?.storageKey };
const trimDurationMs = sourceNode?.metadata?.durationMs || node.metadata?.durationMs;
progress.update(`加载 FFmpeg(${index + 1}/${segments.length})`, index * 4 + 1);
const mp4 = await trimVideoSegment(trimSource, { startMs: segment.startMs, endMs: segment.endMs }, trimDurationMs, (status) => {
progress.update(status.phase === "loading" ? `加载 FFmpeg(${index + 1}/${segments.length})` : status.phase === "reading" ? `读取视频资源(${index + 1}/${segments.length})` : `正在截取片段(${index + 1}/${segments.length})`, status.phase === "encoding" ? index * 4 + 3 : index * 4 + 2);
});
progress.update(`上传片段到服务器(${index + 1}/${segments.length})`, index * 4 + 3);
const uploaded = await uploadMediaFile(mp4, "video");
const size = fitNodeSize(uploaded.width || 1280, uploaded.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
const segmentId = nanoid();
const segmentNode: CanvasNodeData = {
id: segmentId,
type: CanvasNodeType.Video,
title: `片段 ${index + 1} · ${sourceNode?.title || node.title || "视频"}`,
position: { x: baseX, y: baseY + index * (Math.max(size.height, spec.height) + 24) },
width: size.width,
height: size.height,
metadata: { ...videoMetadata(uploaded), prompt: `从「${sourceNode?.title || node.title || "视频"}」截取的片段 ${index + 1}`, status: NODE_STATUS_SUCCESS },
};
const targetId = nanoid();
const targetNode: CanvasNodeData = {
id: targetId,
type: CanvasNodeType.Video,
title: `重生成 ${index + 1} · ${sourceNode?.title || node.title || "视频"}`,
position: { x: segmentNode.position.x + size.width + 96, y: segmentNode.position.y + (size.height - spec.height) / 2 },
width: spec.width,
height: spec.height,
metadata: { prompt: effectivePrompt, status: "idle", generationMode: "video", model: selectedConfig.model, videoEditOperation: params.operation, seconds: generationConfig.videoSeconds, size: generationConfig.size },
};
prepared.push({ segmentNode, targetNode });
} catch (segmentError) {
failedSegments.push(segmentError instanceof Error ? segmentError.message : "视频截取失败");
}
}
if (!prepared.length) throw new Error(failedSegments[0] || "视频截取失败");
const segmentNodes = prepared.map((item) => item.segmentNode);
const targetNodes = prepared.map((item) => item.targetNode);
// 先同步 ref 再 setState,保证生成执行器能立即读到新节点与连接。
const nextNodes = [...nodesRef.current, ...segmentNodes, ...targetNodes];
const nextConnections = [
...connectionsRef.current,
...prepared.flatMap((item) => [
{ id: nanoid(), fromNodeId: node.id, toNodeId: item.segmentNode.id },
{ id: nanoid(), fromNodeId: item.segmentNode.id, toNodeId: item.targetNode.id },
]),
];
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
setSelectedNodeIds(new Set(targetNodes.map((item) => item.id)));
setSelectedConnectionId(null);
progress.update("创建生成任务", segments.length * 4);
progress.done(`已截取 ${prepared.length}/${segments.length} 段,正在调用视频模型重新生成`);
segmentNodes.forEach((segmentNode) => {
void ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: segmentNode, source: "canvas-manual" })
.then((result) => setNodes((current) => current.map((item) => (item.id === segmentNode.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item))))
.catch((assetError) => message.warning(`片段已截取并开始重生成,但素材库写入失败:${assetError instanceof Error ? assetError.message : "未知错误"}`));
});
// 任务提交瞬时打满后端会触发限流;先错峰提交,后续可替换为批次调度的容量控制。
for (let index = 0; index < targetNodes.length; index += 1) {
const targetNode = targetNodes[index];
void onGenerateVideoNode?.(targetNode.id, "video", effectivePrompt);
if (index + 1 < targetNodes.length) await new Promise((resolve) => setTimeout(resolve, 300));
}
if (failedSegments.length) message.warning(`${failedSegments.length} 段截取失败,已为其余 ${prepared.length} 段创建生成任务`);
} catch (error) {
const details = error instanceof Error ? error.message : "视频截取失败";
progress.fail(details);
message.error(details);
}
}, [connectionsRef, domainProjectId, effectiveConfig, message, nodesRef, onGenerateVideoNode, projectId, setConnections, setSelectedConnectionId, setSelectedNodeIds, setNodes, startUploadStatus]);
const handleSegmentConfirm = useCallback(async (node: CanvasNodeData, params: CanvasVideoSegmentParams) => {
if (segmentRunningRef.current || !node.metadata?.content) return;
if (params.mode === "video") {
const generationConfig = buildGenerationConfig(effectiveConfig, node, "video");
const selectedConfig = { ...generationConfig, model: params.model || generationConfig.model };
const batchError = validateVideoSegmentBatch(selectedConfig, params.segments || [], params.operation);
if (batchError) {
message.warning(batchError);
return;
}
}
segmentRunningRef.current = true;
setSegmentRunningMode(params.mode);
setSegmentDialogNodeId(null);
setSegmentDialogMode(null);
try {
if (params.mode === "video") await runTrimVideoAndRegenerate(node, params);
else await runExtractVideoAudio(node, params);
} finally {
segmentRunningRef.current = false;
setSegmentRunningMode(null);
}
}, [effectiveConfig, message, runExtractVideoAudio, runTrimVideoAndRegenerate]);
const mergeVideosByIds = useCallback(async (videoNodeIds: string[]) => {
if (mergeVideoRunningRef.current) return;
const requestedIds = new Set(videoNodeIds);
@@ -505,8 +710,11 @@ export function useCanvasMediaTools({
generatePortraitTextureNode,
cropImageNode,
cropNodeId,
closeSegmentDialog,
extractAudioFromVideo,
extractVideoLastFrame,
extractingVideoFrameNodeId,
handleSegmentConfirm,
generateAngleNode,
maskEditImageNode,
maskEditNodeId,
@@ -514,6 +722,10 @@ export function useCanvasMediaTools({
mergeVideosByIds,
mergeVideoProgress,
saveAnnotatedImageNode,
segmentDialogMode,
segmentDialogNodeId,
segmentRunningMode,
setSegmentDialogNodeId,
setAngleNodeId,
generateEmotionNode,
setEmotionNodeId,
@@ -524,6 +736,7 @@ export function useCanvasMediaTools({
setUpscaleNodeId,
splitImageNode,
splitNodeId,
trimVideoAndRegenerate,
upscaleImageNode,
upscaleNodeId,
};

View File

@@ -255,6 +255,20 @@ export default function CreatePage() {
};
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6;
const uploadCreationAsset = async (file: File) => {
if (file.type.startsWith("video/")) {
const uploaded = await uploadMediaFile(file, "create-upload");
return {
asset: creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }),
attachment: creationAttachmentFromVideo(file, uploaded),
};
}
const uploaded = await uploadImage(file);
return {
asset: creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }),
attachment: creationAttachmentFromImage(file, uploaded),
};
};
const addAttachments = (files: FileList | File[]) => {
if ((mode === "image" || mode === "video") && maxReferences === 0) {
toast.warning(mode === "image" ? "当前图片模型不支持参考图" : "当前模型不支持图生视频");
@@ -265,14 +279,9 @@ export default function CreatePage() {
.slice(0, Math.max(0, maxReferences - attachments.length));
if (!next.length) return;
void Promise.allSettled(next.map(async (file) => {
if (file.type.startsWith("video/")) {
const uploaded = await uploadMediaFile(file, "create-upload");
addAsset(creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }));
return creationAttachmentFromVideo(file, uploaded);
}
const uploaded = await uploadImage(file);
addAsset(creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }));
return creationAttachmentFromImage(file, uploaded);
const { asset, attachment } = await uploadCreationAsset(file);
addAsset(asset);
return attachment;
})).then((settled) => {
const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
const failed = settled.filter((entry) => entry.status === "rejected");
@@ -281,6 +290,19 @@ export default function CreatePage() {
});
};
const uploadLibraryAssets = async (files: FileList | File[]) => {
const next = Array.from(files).filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/")));
if (!next.length) return [];
const settled = await Promise.allSettled(next.map(async (file) => {
const { asset } = await uploadCreationAsset(file);
return addAsset(asset);
}));
const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
const failed = settled.filter((entry) => entry.status === "rejected");
if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`);
return assetIds;
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files) addAttachments(event.target.files);
event.target.value = "";
@@ -542,16 +564,18 @@ export default function CreatePage() {
</main>
</div>
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} />
<CreationAssetLibraryModal open={libraryOpen} assets={assets} mode={mode} selectedIds={new Set(attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={() => fileInputRef.current?.click()} />
<CreationAssetLibraryModal open={libraryOpen} assets={assets} mode={mode} selectedIds={new Set(attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={uploadLibraryAssets} />
</>;
}
const creationAssetCategoryLabels: Record<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set<string>; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: () => void }) {
function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set<string>; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: (files: FileList | File[]) => Promise<string[]> }) {
const [category, setCategory] = useState("all");
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [uploading, setUploading] = useState(false);
const uploadInputRef = useRef<HTMLInputElement>(null);
const mediaAssets = useMemo(() => assets.filter((asset): asset is Extract<Asset, { kind: "image" | "video" }> => asset.kind === "image" || asset.kind === "video"), [assets]);
const categories = useMemo(() => ["all", ...Array.from(new Set(mediaAssets.map((asset) => asset.category || "other")))], [mediaAssets]);
const visibleAssets = useMemo(() => {
@@ -574,6 +598,17 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o
return next;
});
};
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files || uploading) return;
setUploading(true);
try {
const assetIds = await onUpload(event.target.files);
if (assetIds.length) setSelected((current) => new Set([...current, ...assetIds]));
} finally {
event.target.value = "";
setUploading(false);
}
};
const selectedAssets = mediaAssets.filter((asset) => selected.has(asset.id) && (mode === "video" || asset.kind === "image"));
const count = category === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === category).length;
@@ -584,7 +619,7 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o
<nav className="creation-library-categories" aria-label="素材分类">{categories.map((item) => <button key={item} type="button" className={category === item ? "is-active" : ""} onClick={() => setCategory(item)}><span>{creationAssetCategoryLabels[item] || "其他"}</span><em>{item === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === item).length}</em></button>)}</nav>
<div className="creation-library-grid-wrap"><div className="creation-library-grid">{visibleAssets.length ? visibleAssets.map((asset) => <CreationLibraryCard key={asset.id} asset={asset} selected={selected.has(asset.id)} disabled={mode !== "video" && asset.kind === "video"} onToggle={() => toggle(asset)} />) : <div className="creation-library-empty"><FolderOpen /><strong>这个分类还没有素材</strong><span>换个分类,或从底部上传一份新素材。</span></div>}</div></div>
</div>
<footer className="creation-library-footer"><button type="button" className="creation-library-upload" onClick={onUpload}><Upload /><span><strong>找不到素材,手动上传</strong><small>支持图片{mode === "video" ? "和视频" : ""},可一次选择多个文件</small></span></button><div className="creation-library-actions"><button type="button" onClick={onClose}>取消</button><button type="button" className="is-primary" disabled={!selectedAssets.length} onClick={() => onConfirm(selectedAssets)}><Check />使用已选素材{selectedAssets.length ? `(${selectedAssets.length})` : ""}</button></div></footer>
<footer className="creation-library-footer"><input ref={uploadInputRef} type="file" hidden accept={mode === "video" ? "image/*,video/*" : "image/*"} multiple onChange={handleUpload} /><button type="button" className="creation-library-upload" onClick={() => uploadInputRef.current?.click()} disabled={uploading}><Upload /><span><strong>{uploading ? "正在上传素材" : "找不到素材,手动上传"}</strong><small>支持图片{mode === "video" ? "和视频" : ""},可一次选择多个文件</small></span></button><div className="creation-library-actions"><button type="button" onClick={onClose}>取消</button><button type="button" className="is-primary" disabled={!selectedAssets.length} onClick={() => onConfirm(selectedAssets)}><Check />使用已选素材{selectedAssets.length ? `(${selectedAssets.length})` : ""}</button></div></footer>
</div>
</Modal>;
}
@@ -739,6 +774,7 @@ function CreationComposer(props: ComposerProps) {
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
/>
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><FolderOpen /><span>素材库</span></button></Tooltip>
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} variant="creation" />
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? <GenerationSettingsMenu {...props} /> : null}
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}