refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态
This commit is contained in:
@@ -7,6 +7,7 @@ import { generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/gen
|
||||
import { formatTaskLog, type GenerationTask, type TaskLog } from "@/services/api/task-center";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { VideoPlayer } from "@/components/video-player";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
type CanvasProjectStatusDialogsProps = {
|
||||
@@ -74,19 +75,20 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
|
||||
<div className="py-8 text-center text-base font-medium">暂未实现</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
<AppModal
|
||||
title="视频预览"
|
||||
open={Boolean(previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video)}
|
||||
centered
|
||||
onCancel={onClosePreview}
|
||||
footer={null}
|
||||
width="min(1200px, calc(100vw - 32px))"
|
||||
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "#090909" } }}
|
||||
flush
|
||||
styles={{ body: { display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "var(--workspace-canvas-deep)" } }}
|
||||
>
|
||||
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? (
|
||||
<VideoPlayer src={previewNode.metadata.content} mimeType={previewNode.metadata.mimeType} title={previewNode.title || "视频预览"} hasAudio={typeof previewNode.metadata.hasAudio === "boolean" ? previewNode.metadata.hasAudio : undefined} className="max-h-[84vh] max-w-full bg-black" />
|
||||
) : null}
|
||||
</Modal>
|
||||
</AppModal>
|
||||
|
||||
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Image ? (
|
||||
<CanvasImagePreview
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Command, Search } from "lucide-react";
|
||||
import { Kbd } from "@/components/ui/base/kbd";
|
||||
import { Modal } from "antd";
|
||||
|
||||
import {
|
||||
CANVAS_SHORTCUT_CATEGORIES,
|
||||
@@ -31,7 +31,7 @@ export function CanvasShortcutsModal({ open, onClose }: { open: boolean; onClose
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
className="workspace-modal workspace-modal-wide canvas-shortcuts-modal"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
@@ -40,7 +40,7 @@ export function CanvasShortcutsModal({ open, onClose }: { open: boolean; onClose
|
||||
centered
|
||||
keyboard
|
||||
width="min(860px, calc(100vw - 24px))"
|
||||
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
|
||||
flush
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) window.requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}}
|
||||
@@ -109,7 +109,7 @@ export function CanvasShortcutsModal({ open, onClose }: { open: boolean; onClose
|
||||
<span className="canvas-shortcuts-close-hint"><Kbd>Esc</Kbd> 关闭</span>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ import { CanvasVideoFrameDialog } from "@/components/canvas/canvas-video-frame-d
|
||||
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";
|
||||
import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
|
||||
import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
|
||||
import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
|
||||
@@ -60,7 +59,7 @@ import { Minimap } from "@/components/canvas/canvas-mini-map";
|
||||
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
|
||||
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { AssetPickerModal } from "@/components/canvas/asset-picker-modal";
|
||||
import { getProject } from "@/services/api/projects";
|
||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
|
||||
@@ -126,6 +125,7 @@ import { useCanvasSelectionController } from "./use-canvas-selection-controller"
|
||||
import { useCanvasShortDrama } from "./use-canvas-short-drama";
|
||||
import { useCanvasStoryboard } from "./use-canvas-storyboard";
|
||||
import { useCanvasUpload } from "./use-canvas-upload";
|
||||
import { useCanvasTimelineAssetInsert } from "./use-canvas-timeline-asset-insert";
|
||||
import { useCanvasViewportController } from "./use-canvas-viewport-controller";
|
||||
import { usePortraitClearanceCoordinator } from "./use-portrait-clearance-coordinator";
|
||||
import {
|
||||
@@ -205,7 +205,8 @@ export default function CanvasPage() {
|
||||
}
|
||||
|
||||
function InfiniteCanvasPage() {
|
||||
const { message } = App.useApp();
|
||||
// 命令式确认必须走 App.useApp().modal;静态 Modal.confirm 拿不到主题和 App 上下文。
|
||||
const { message, modal } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const params = useParams<{ id: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -281,10 +282,6 @@ function InfiniteCanvasPage() {
|
||||
const [stylePickerOpen, setStylePickerOpen] = useState(false);
|
||||
// 新建导演台镜头必须先选模板:null 表示未在选择中,undefined position 表示用画布中心。
|
||||
const [directorTemplateRequest, setDirectorTemplateRequest] = useState<{ position?: Position } | null>(null);
|
||||
const [projectAssetOpen, setProjectAssetOpen] = useState(false);
|
||||
const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all");
|
||||
const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all");
|
||||
const [projectAssetInsertPosition, setProjectAssetInsertPosition] = useState<Position | undefined>();
|
||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||
const [subtitleNodeId, setSubtitleNodeId] = useState<string | null>(null);
|
||||
const [timelineNodeId, setTimelineNodeId] = useState<string | null>(null);
|
||||
@@ -523,7 +520,7 @@ function InfiniteCanvasPage() {
|
||||
message.warning("官方即梦 CLI 当前不支持可靠取消,请等待官方状态同步");
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: "取消生成任务?",
|
||||
content: "任务会立即停止本地执行;如果已经提交到上游,系统会继续核对取消结果和积分状态。",
|
||||
okText: "取消任务",
|
||||
@@ -543,7 +540,7 @@ function InfiniteCanvasPage() {
|
||||
},
|
||||
});
|
||||
},
|
||||
[bindGenerationTask, message, nodesRef, projectId, queryClient, setTaskDetail],
|
||||
[bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -703,6 +700,28 @@ function InfiniteCanvasPage() {
|
||||
setDialogNodeId,
|
||||
});
|
||||
const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]);
|
||||
const {
|
||||
timelineAddNodeRef,
|
||||
timelineMediaAddRef,
|
||||
assetInsertScope,
|
||||
projectAssetScope,
|
||||
projectAssetOpen,
|
||||
projectAssetInitialCategory,
|
||||
projectAssetInitialFolderId,
|
||||
projectAssetInsertPosition,
|
||||
handleLibraryAssetsInsert,
|
||||
handleTimelineProjectAssetsInsert,
|
||||
openProjectAssets,
|
||||
openCanvasAssetLibrary,
|
||||
openTimelineAssetLibrary,
|
||||
closeProjectAssets,
|
||||
} = useCanvasTimelineAssetInsert({
|
||||
linkedProjectId,
|
||||
refetchLinkedProject,
|
||||
handleAssetsInsert,
|
||||
handleProjectAssetsInsert,
|
||||
openAssetsAtPosition,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || searchParams.get("mode") !== "handoff") return;
|
||||
@@ -745,103 +764,6 @@ function InfiniteCanvasPage() {
|
||||
});
|
||||
}, [assets, assetsHydrated, handleProjectAssetsInsert, message, nodesRef, projectId, projectLoaded, searchParams, setSearchParams, updateProject]);
|
||||
|
||||
// 时间线弹窗内新增素材的回填通道:素材库/上传创建节点后由弹窗通过 ref 加入草稿。
|
||||
const timelineAddNodeRef = useRef<((node: CanvasNodeData) => void) | null>(null);
|
||||
// 时间线作用域直连媒体入轨通道:素材库/项目资产/本地上传不落画布,仅加入时间线草稿。
|
||||
const timelineMediaAddRef = useRef<((media: TimelineDirectMedia) => void) | null>(null);
|
||||
// 素材库与项目资产弹窗的插入作用域:时间线弹窗内打开时为 timeline,其余为 canvas。
|
||||
const [assetInsertScope, setAssetInsertScope] = useState<"canvas" | "timeline">("canvas");
|
||||
const [projectAssetScope, setProjectAssetScope] = useState<"canvas" | "timeline">("canvas");
|
||||
|
||||
// InsertAssetPayload → 直连媒体:仅音视频支持直接入轨;图片/文本/角色返回 null(避免在画布重复出现)。
|
||||
const payloadToTimelineMedia = (payload: InsertAssetPayload): TimelineDirectMedia | null => {
|
||||
const randomSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
if (payload.kind === "video") {
|
||||
return {
|
||||
id: payload.assetId || `asset-${randomSuffix}`,
|
||||
kind: "video",
|
||||
title: payload.title,
|
||||
storageKey: payload.storageKey,
|
||||
url: payload.url,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
durationMs: payload.durationMs,
|
||||
bytes: payload.bytes,
|
||||
mimeType: payload.mimeType,
|
||||
};
|
||||
}
|
||||
if (payload.kind === "audio") {
|
||||
return { id: payload.assetId || `asset-${randomSuffix}`, kind: "audio", title: payload.title, storageKey: payload.storageKey, url: payload.url, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleLibraryAssetsInsert = useCallback(
|
||||
async (payloads: InsertAssetPayload[]) => {
|
||||
if (assetInsertScope === "timeline") {
|
||||
const media = payloads.map(payloadToTimelineMedia).filter((item): item is TimelineDirectMedia => Boolean(item));
|
||||
if (media.length !== payloads.length) throw new Error("图片和文本素材暂不支持直接入轨,请先插入画布");
|
||||
media.forEach((item) => timelineMediaAddRef.current?.(item));
|
||||
return;
|
||||
}
|
||||
const created = await handleAssetsInsert(payloads);
|
||||
created.forEach((node) => timelineAddNodeRef.current?.(node));
|
||||
},
|
||||
[assetInsertScope, handleAssetsInsert],
|
||||
);
|
||||
|
||||
// 项目资产库引入到时间线:复用现有引入逻辑,把创建出的节点回填到弹窗草稿。
|
||||
const handleTimelineProjectAssetsInsert = useCallback(
|
||||
async (payloads: InsertAssetPayload[]) => {
|
||||
if (projectAssetScope === "timeline") {
|
||||
let inserted = 0;
|
||||
for (const payload of payloads) {
|
||||
const media = payloadToTimelineMedia(payload);
|
||||
if (media) {
|
||||
timelineMediaAddRef.current?.(media);
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
if (inserted < payloads.length) message.info("图片/文本/角色素材暂不支持直接入轨,仅音视频素材已加入时间线");
|
||||
return;
|
||||
}
|
||||
const created = await handleProjectAssetsInsert(payloads, projectAssetInsertPosition);
|
||||
created.forEach((node) => timelineAddNodeRef.current?.(node));
|
||||
},
|
||||
[handleProjectAssetsInsert, message, projectAssetInsertPosition, projectAssetScope],
|
||||
);
|
||||
|
||||
const openProjectAssets = useCallback(
|
||||
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas", initialFolderId = "all") => {
|
||||
setProjectAssetScope(scope);
|
||||
setProjectAssetInitialCategory(initialCategory);
|
||||
setProjectAssetInitialFolderId(initialFolderId);
|
||||
setProjectAssetInsertPosition(position);
|
||||
setProjectAssetOpen(true);
|
||||
// 资产与项目实时同步:打开弹窗前刷新关联短剧项目资产,避免缓存导致资产列表空白/过期。
|
||||
if (linkedProjectId) void refetchLinkedProject();
|
||||
},
|
||||
[linkedProjectId, refetchLinkedProject],
|
||||
);
|
||||
|
||||
// 素材库打开入口:画布作用域(工具栏/空态/侧栏)与时间线作用域(时间线弹窗)分别标记插入目标。
|
||||
const openCanvasAssetLibrary = useCallback(
|
||||
(position?: Position) => {
|
||||
setAssetInsertScope("canvas");
|
||||
openAssetsAtPosition(position);
|
||||
},
|
||||
[openAssetsAtPosition],
|
||||
);
|
||||
const openTimelineAssetLibrary = useCallback(() => {
|
||||
setAssetInsertScope("timeline");
|
||||
openAssetsAtPosition();
|
||||
}, [openAssetsAtPosition]);
|
||||
const closeProjectAssets = useCallback(() => {
|
||||
setProjectAssetOpen(false);
|
||||
setProjectAssetInsertPosition(undefined);
|
||||
setProjectAssetInitialFolderId("all");
|
||||
}, []);
|
||||
|
||||
const {
|
||||
angleNodeId,
|
||||
emotionNodeId,
|
||||
@@ -1739,14 +1661,14 @@ function InfiniteCanvasPage() {
|
||||
}, [arkPrivateAssetUploadNodeId, currentProject?.projectId, handleConfigNodeChange, message, projectId]);
|
||||
|
||||
const confirmUploadNodeImageToArkPrivateAsset = useCallback((node: CanvasNodeData) => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: "上传到方舟素材库",
|
||||
content: "仅可上传你拥有肖像、版权或其他合法使用权的图片。方舟审核通过后,Seedance 会使用受控素材标识生成视频。",
|
||||
okText: "确认拥有使用权并上传",
|
||||
cancelText: "取消",
|
||||
onOk: () => uploadNodeImageToArkPrivateAsset(node),
|
||||
});
|
||||
}, [uploadNodeImageToArkPrivateAsset]);
|
||||
}, [modal, uploadNodeImageToArkPrivateAsset]);
|
||||
|
||||
const handleCanvasContextMenu = useCallback(
|
||||
(event: ReactMouseEvent) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas
|
||||
import { canvasGenerationPromptMetadata, canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
|
||||
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
|
||||
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import { buildTextRewritePrompt } from "@/lib/prompts";
|
||||
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
|
||||
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
|
||||
import { modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection";
|
||||
@@ -143,7 +144,7 @@ export function useCanvasGenerationExecutor({
|
||||
nodeId,
|
||||
nodesRef.current,
|
||||
connectionsRef.current,
|
||||
editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt,
|
||||
editingTextNode ? buildTextRewritePrompt(sourceTextContent, prompt) : generationPrompt,
|
||||
assets,
|
||||
promptOnly,
|
||||
);
|
||||
|
||||
@@ -29,6 +29,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 { IMAGE_PROMPT_REVERSE } from "@/lib/prompts";
|
||||
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";
|
||||
@@ -71,12 +72,6 @@ const NODE_STATUS_LOADING = "loading" as const;
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const NODE_STATUS_ERROR = "error" as const;
|
||||
const NODE_STATUS_IDLE = "idle" as const;
|
||||
const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。
|
||||
|
||||
要求:
|
||||
1. 只输出提示词正文,不要解释。
|
||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
||||
|
||||
export function useCanvasMediaTools({
|
||||
projectId,
|
||||
@@ -160,7 +155,7 @@ export function useCanvasMediaTools({
|
||||
const resultSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
|
||||
const centerY = node.position.y + node.height / 2;
|
||||
const textNode = {
|
||||
...createCanvasNode(CanvasNodeType.Text, { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, { content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 }),
|
||||
...createCanvasNode(CanvasNodeType.Text, { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, { content: IMAGE_PROMPT_REVERSE, prompt: IMAGE_PROMPT_REVERSE, status: NODE_STATUS_SUCCESS, fontSize: 14 }),
|
||||
title: "反推提示词",
|
||||
};
|
||||
const resultNode = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -244,8 +244,8 @@ export function useCanvasProjectLifecycle({
|
||||
await saveRemoteUserDataNow();
|
||||
message.success("画布布局和位置已保存");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "未知错误";
|
||||
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
|
||||
// 本地 IndexedDB 已写入;云端失败只排队重试,不能把导入方的画布状态回滚。
|
||||
message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error));
|
||||
}
|
||||
return true;
|
||||
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
|
||||
|
||||
138
web/src/pages/canvas/use-canvas-timeline-asset-insert.ts
Normal file
138
web/src/pages/canvas/use-canvas-timeline-asset-insert.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { App } from "antd";
|
||||
|
||||
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import type { CanvasNodeData, Position } from "@/types/canvas";
|
||||
import type { TimelineDirectMedia } from "@/types/timeline";
|
||||
|
||||
type UseCanvasTimelineAssetInsertOptions = {
|
||||
linkedProjectId?: string;
|
||||
refetchLinkedProject: () => Promise<unknown>;
|
||||
handleAssetsInsert: (payloads: InsertAssetPayload[]) => Promise<CanvasNodeData[]>;
|
||||
handleProjectAssetsInsert: (payloads: InsertAssetPayload[], position?: Position) => Promise<CanvasNodeData[]>;
|
||||
openAssetsAtPosition: (position?: Position) => void;
|
||||
};
|
||||
|
||||
function payloadToTimelineMedia(payload: InsertAssetPayload): TimelineDirectMedia | null {
|
||||
const randomSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
if (payload.kind === "video") {
|
||||
return {
|
||||
id: payload.assetId || `asset-${randomSuffix}`,
|
||||
kind: "video",
|
||||
title: payload.title,
|
||||
storageKey: payload.storageKey,
|
||||
url: payload.url,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
durationMs: payload.durationMs,
|
||||
bytes: payload.bytes,
|
||||
mimeType: payload.mimeType,
|
||||
};
|
||||
}
|
||||
if (payload.kind === "audio") {
|
||||
return { id: payload.assetId || `asset-${randomSuffix}`, kind: "audio", title: payload.title, storageKey: payload.storageKey, url: payload.url, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useCanvasTimelineAssetInsert({
|
||||
linkedProjectId,
|
||||
refetchLinkedProject,
|
||||
handleAssetsInsert,
|
||||
handleProjectAssetsInsert,
|
||||
openAssetsAtPosition,
|
||||
}: UseCanvasTimelineAssetInsertOptions) {
|
||||
const { message } = App.useApp();
|
||||
// 时间线弹窗内新增素材的回填通道:素材库/上传创建节点后由弹窗通过 ref 加入草稿。
|
||||
const timelineAddNodeRef = useRef<((node: CanvasNodeData) => void) | null>(null);
|
||||
// 时间线作用域直连媒体入轨通道:素材库/项目资产/本地上传不落画布,仅加入时间线草稿。
|
||||
const timelineMediaAddRef = useRef<((media: TimelineDirectMedia) => void) | null>(null);
|
||||
// 素材库与项目资产弹窗的插入作用域:时间线弹窗内打开时为 timeline,其余为 canvas。
|
||||
const [assetInsertScope, setAssetInsertScope] = useState<"canvas" | "timeline">("canvas");
|
||||
const [projectAssetScope, setProjectAssetScope] = useState<"canvas" | "timeline">("canvas");
|
||||
const [projectAssetOpen, setProjectAssetOpen] = useState(false);
|
||||
const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all");
|
||||
const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all");
|
||||
const [projectAssetInsertPosition, setProjectAssetInsertPosition] = useState<Position | undefined>();
|
||||
|
||||
const handleLibraryAssetsInsert = useCallback(
|
||||
async (payloads: InsertAssetPayload[]) => {
|
||||
if (assetInsertScope === "timeline") {
|
||||
const media = payloads.map(payloadToTimelineMedia).filter((item): item is TimelineDirectMedia => Boolean(item));
|
||||
if (media.length !== payloads.length) throw new Error("图片和文本素材暂不支持直接入轨,请先插入画布");
|
||||
media.forEach((item) => timelineMediaAddRef.current?.(item));
|
||||
return;
|
||||
}
|
||||
const created = await handleAssetsInsert(payloads);
|
||||
created.forEach((node) => timelineAddNodeRef.current?.(node));
|
||||
},
|
||||
[assetInsertScope, handleAssetsInsert],
|
||||
);
|
||||
|
||||
const handleTimelineProjectAssetsInsert = useCallback(
|
||||
async (payloads: InsertAssetPayload[]) => {
|
||||
if (projectAssetScope === "timeline") {
|
||||
let inserted = 0;
|
||||
for (const payload of payloads) {
|
||||
const media = payloadToTimelineMedia(payload);
|
||||
if (media) {
|
||||
timelineMediaAddRef.current?.(media);
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
if (inserted < payloads.length) message.info("图片/文本/角色素材暂不支持直接入轨,仅音视频素材已加入时间线");
|
||||
return;
|
||||
}
|
||||
const created = await handleProjectAssetsInsert(payloads, projectAssetInsertPosition);
|
||||
created.forEach((node) => timelineAddNodeRef.current?.(node));
|
||||
},
|
||||
[handleProjectAssetsInsert, message, projectAssetInsertPosition, projectAssetScope],
|
||||
);
|
||||
|
||||
const openProjectAssets = useCallback(
|
||||
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas", initialFolderId = "all") => {
|
||||
setProjectAssetScope(scope);
|
||||
setProjectAssetInitialCategory(initialCategory);
|
||||
setProjectAssetInitialFolderId(initialFolderId);
|
||||
setProjectAssetInsertPosition(position);
|
||||
setProjectAssetOpen(true);
|
||||
// 资产与项目实时同步:打开弹窗前刷新关联短剧项目资产,避免缓存导致资产列表空白/过期。
|
||||
if (linkedProjectId) void refetchLinkedProject();
|
||||
},
|
||||
[linkedProjectId, refetchLinkedProject],
|
||||
);
|
||||
|
||||
const openCanvasAssetLibrary = useCallback(
|
||||
(position?: Position) => {
|
||||
setAssetInsertScope("canvas");
|
||||
openAssetsAtPosition(position);
|
||||
},
|
||||
[openAssetsAtPosition],
|
||||
);
|
||||
const openTimelineAssetLibrary = useCallback(() => {
|
||||
setAssetInsertScope("timeline");
|
||||
openAssetsAtPosition();
|
||||
}, [openAssetsAtPosition]);
|
||||
const closeProjectAssets = useCallback(() => {
|
||||
setProjectAssetOpen(false);
|
||||
setProjectAssetInsertPosition(undefined);
|
||||
setProjectAssetInitialFolderId("all");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
timelineAddNodeRef,
|
||||
timelineMediaAddRef,
|
||||
assetInsertScope,
|
||||
projectAssetScope,
|
||||
projectAssetOpen,
|
||||
projectAssetInitialCategory,
|
||||
projectAssetInitialFolderId,
|
||||
projectAssetInsertPosition,
|
||||
handleLibraryAssetsInsert,
|
||||
handleTimelineProjectAssetsInsert,
|
||||
openProjectAssets,
|
||||
openCanvasAssetLibrary,
|
||||
openTimelineAssetLibrary,
|
||||
closeProjectAssets,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user