feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互

This commit is contained in:
ddcat666 committed 2026-10-01 09:43:02 +08:00
1 parent 5650da6d31
commit 22e7331d04
77 files changed
+3641 -721

No files matched your search

@@ -7,6 +7,7 @@ import { nextCanvasVersionLabel } from "@/lib/canvas/canvas-layout";
import { buildAudioGenerationMetadata, buildVideoGenerationMetadata, generationReferenceUrls, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { producedModelCandidateForGeneration } from "@/lib/canvas/produced-model";
import { isDoubaoAudioConfig } from "@/lib/audio-generation";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasGenerationExecution } from "./canvas-generation-executor-types";
@@ -179,8 +180,10 @@ export async function executeAudioGeneration({
nodeId: audioId,
...retryContext,
mode: "audio",
prompt: effectivePrompt,
prompt: doubaoAudioPrompt(generationConfig, effectivePrompt, generationContext.referenceAudios.length, generationContext.referenceImages.length),
config: generationConfig,
referenceImages: generationContext.referenceImages,
referenceAudios: generationContext.referenceAudios,
signal: controller.signal,
metadata: { sourceNodeId: nodeId, ...taskContext, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoiceKey: generationContext.resolvedCharacterVoices[0]?.voiceKey, ...skillMetadata },
},
@@ -193,3 +196,14 @@ export async function executeAudioGeneration({
finishGenerationRequest(audioId, controller);
}
}
function doubaoAudioPrompt(config: CanvasGenerationExecution["generationConfig"], prompt: string, audioCount: number, imageCount: number) {
if (!isDoubaoAudioConfig(config)) return prompt;
// 参考图片模式的 text_prompt 只保留要合成的文本,图片走 references。
if (imageCount > 0) return prompt.replace(/@(图片|音频|视频|绘图|角色|文本)\d+/g, " ").replace(/[ \t]{2,}/g, " ").trim();
if (audioCount <= 0) return prompt;
// 官方参考音频用 @Audio1、@Audio2,和 references 的顺序一致。画布里的 @音频N 只是编辑器槽位。
const next = prompt.replace(/@音频(\d+)/g, "@Audio$1");
const missing = Array.from({ length: audioCount }, (_, index) => `@Audio${index + 1}`).filter((marker) => !next.includes(marker));
return missing.length ? `${missing.join(" ")} ${next}` : next;
}
+21 -21
View File
@@ -19,17 +19,20 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
const error = phase === "error";
const retryScheduled = error && progress?.message?.includes("自动重试");
const saving = phase === "pending" || phase === "saving" || phase === "uploading";
const tone = conflict ? "conflict" : error ? "error" : saving ? "pending" : phase === "done" ? "done" : "idle";
const label = conflict ? "需要处理冲突" : error ? "云端保存失败" : saving ? "正在同步" : phase === "done" ? "已保存到云端" : "尚未同步";
const reconciling = phase === "reconciling";
const tone = conflict ? "conflict" : error ? "error" : saving || reconciling ? "pending" : phase === "done" ? "done" : "idle";
const label = conflict ? "需要处理冲突" : error ? "云端保存失败" : reconciling ? "正在自动合并" : saving ? "正在同步" : phase === "done" ? "已保存到云端" : "尚未同步";
const description = conflict
? "本地修改没有被覆盖,已保存为本地草稿。"
? "本地与云端修改了同一字段,系统已保留本地草稿。"
: error
? progress?.message || "本地内容仍然保留,云端暂未确认。"
: saving
? progress?.message || "正在把本地修改同步到云端。"
: phase === "done"
? progress?.message || "当前画布与云端版本一致。"
: "本地内容会先保存到浏览器,再尝试同步到云端。";
: reconciling
? progress?.message || "正在合并云端与本地修改,完成后会自动继续保存。"
: saving
? progress?.message || "正在把本地修改同步到云端。"
: phase === "done"
? progress?.message || "当前画布与云端版本一致。"
: "本地内容会先保存到浏览器,再尝试同步到云端。";
const run = async (operation: () => Promise<unknown>) => {
setBusy(true);
@@ -84,7 +87,9 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
<strong>{label}</strong>
</div>
</div>
<p className="canvas-sync-panel__description" role="status">{description}</p>
<p className="canvas-sync-panel__description" role="status">
{description}
</p>
{conflict ? (
<div className="canvas-sync-panel__callout">
<strong>先保护本地,再决定版本</strong>
@@ -93,11 +98,7 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
) : error ? (
<div className="canvas-sync-panel__callout">
<strong>{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}</strong>
<span>
{retryScheduled
? "网络恢复后会自动重试;也可以点击“立即重试”。"
: "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}
</span>
<span>{retryScheduled ? "网络恢复后会自动重试;也可以点击“立即重试”。" : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}</span>
</div>
) : null}
<div className="canvas-sync-panel__actions">
@@ -107,17 +108,16 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
block
icon={<RefreshCw className="size-3.5" />}
loading={busy}
onClick={() => void run(() => retryRemoteUserDataSync(projectId)).then(() => setStatusOpen(false)).catch(() => undefined)}
onClick={() =>
void run(() => retryRemoteUserDataSync(projectId))
.then(() => setStatusOpen(false))
.catch(() => undefined)
}
>
立即重试
</Button>
) : null}
<Button
block
icon={<CloudOff className="size-3.5" />}
disabled={busy || saving}
onClick={confirmLoadLatest}
>
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving} onClick={confirmLoadLatest}>
加载云端版
</Button>
<div className="canvas-sync-panel__secondary-actions">
+45 -6
View File
@@ -38,6 +38,7 @@ import { CanvasCloudAgentPanel } from "@/components/canvas/canvas-cloud-agent-pa
import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel";
import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar";
import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asset-modal";
import { CanvasCharacterLibraryModal } from "@/components/canvas/canvas-character-library-modal";
import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node";
import { CanvasCharacterReferenceModal } from "@/components/canvas/canvas-character-reference-modal";
import { WorkspaceState } from "@/components/layout/workspace-state";
@@ -65,7 +66,7 @@ import { handleListGenerate } from "./list-mode-generator";
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
import { AssetPickerModal } from "@/components/canvas/asset-picker-modal";
import { getProject } from "@/services/api/projects";
import { getProject, listCharacters, type ProjectAsset } from "@/services/api/projects";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node";
@@ -259,6 +260,8 @@ function InfiniteCanvasPage() {
const [dialogNodeId, setDialogNodeId] = useState<string | null>(null);
const [textEditorNodeId, setTextEditorNodeId] = useState<string | null>(null);
const [characterReferenceNodeId, setCharacterReferenceNodeId] = useState<string | null>(null);
const [characterLibraryOpen, setCharacterLibraryOpen] = useState(false);
const [characterLibraryPosition, setCharacterLibraryPosition] = useState<Position | undefined>();
const [drawingNodeId, setDrawingNodeId] = useState<string | null>(null);
const [stylePickerOpen, setStylePickerOpen] = useState(false);
// 新建导演台镜头必须先选模板:null 表示未在选择中,undefined position 表示用画布中心。
@@ -500,6 +503,20 @@ function InfiniteCanvasPage() {
if (!projectLoaded || !linkedProjectQuery.data) return;
setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets));
}, [linkedProjectQuery.data, projectLoaded, setNodes]);
const canvasCharacterIds = useMemo(() => {
const ids = new Set<string>();
for (const node of nodes) {
const assetId = node.metadata?.workflowKind === "character" ? node.metadata.characterAssetId?.trim() : "";
if (assetId) ids.add(assetId);
}
return [...ids].sort();
}, [nodes]);
const characterCardsQuery = useQuery({ queryKey: ["characters", "canvas-refresh", canvasCharacterIds], queryFn: () => listCharacters({ ids: canvasCharacterIds }), enabled: projectLoaded && canvasCharacterIds.length > 0 });
useEffect(() => {
if (!projectLoaded || !characterCardsQuery.data) return;
const assets = characterCardsQuery.data.characters.map((item) => ({ ...item.asset, character: item.character })) as ProjectAsset[];
setNodes((current) => refreshCanvasCharacterReferenceNodes(current, assets));
}, [characterCardsQuery.data, projectLoaded, setNodes]);
const canvasContext = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds, linkedProjectQuery.data?.units), [linkedProjectQuery.data?.units, nodes, selectedNodeIds]);
// 扩展节点(对比/图表/调色)要读自己的上游才能渲染,经 Context 下发;
// 取上游复用 canvas-resource-references 的实现,别在这里另写一份。必须 memo——
@@ -741,6 +758,13 @@ function InfiniteCanvasPage() {
handleProjectAssetsInsert,
openAssetsAtPosition,
});
const openCharacterLibrary = useCallback((position?: Position) => {
setCharacterLibraryPosition(position);
setCharacterLibraryOpen(true);
setContextMenu(null);
}, []);
const selectedCharacterImage = nodes.find((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Image && resourceIdFromStorageKey(node.metadata?.storageKey));
const selectedCharacterAudio = nodes.find((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Audio && resourceIdFromStorageKey(node.metadata?.storageKey));
useEffect(() => {
if (!projectLoaded || searchParams.get("mode") !== "handoff") return;
@@ -1086,6 +1110,9 @@ function InfiniteCanvasPage() {
setDrawingNodeId(node.id);
} else if (node.type === CanvasNodeType.Script) {
setDialogNodeId(null);
} else if (node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) {
setDialogNodeId(null);
setCharacterReferenceNodeId(node.id);
} else if (node.type === CanvasNodeType.Text) {
setDialogNodeId(node.id);
} else if (node.type === CanvasNodeType.Frame) {
@@ -1119,7 +1146,7 @@ function InfiniteCanvasPage() {
const handleNodeDragEnd = useCallback(
(nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === ART_CRITIQUE_NODE_TYPE) {
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === ART_CRITIQUE_NODE_TYPE || (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId)) {
setDialogNodeId(null);
return;
}
@@ -2434,7 +2461,7 @@ function InfiniteCanvasPage() {
onOpenDirector: () => setDirectorTemplateRequest({}),
onUpload: () => handleUploadRequest(),
onOpenMyAssets: () => openCanvasAssetLibrary(),
onOpenProjectCharacters: () => openProjectAssets("character"),
onOpenProjectCharacters: () => openCharacterLibrary(),
},
});
const emptyStateKind = resolveCanvasEmptyStateKind({
@@ -2754,7 +2781,7 @@ function InfiniteCanvasPage() {
onOpenMyAssets={() => {
openCanvasAssetLibrary();
}}
onOpenProjectCharacters={() => openProjectAssets("character")}
onOpenProjectCharacters={() => openCharacterLibrary()}
/>
) : null}
</div>
@@ -2831,6 +2858,7 @@ function InfiniteCanvasPage() {
dialogNode.type !== CanvasNodeType.BatchTable &&
dialogNode.type !== CanvasNodeType.Drawing &&
dialogNode.type !== CanvasNodeType.Panorama &&
!(dialogNode.metadata?.workflowKind === "character" && dialogNode.metadata.characterAssetId) &&
!selectionBox &&
!isCanvasNodeMoving ? (
<CanvasNodePanelOverlay
@@ -3004,7 +3032,7 @@ function InfiniteCanvasPage() {
onOpenDirector={(position) => setDirectorTemplateRequest({ position })}
onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
onOpenAssets={openCanvasAssetLibrary}
onOpenProjectCharacters={(position) => openProjectAssets("character", position)}
onOpenProjectCharacters={(position) => openCharacterLibrary(position)}
onUndo={undoCanvas}
onRedo={redoCanvas}
onPaste={pasteAtPosition}
@@ -3103,7 +3131,7 @@ function InfiniteCanvasPage() {
/>
) : null}
<CanvasCharacterReferenceModal node={characterReferenceNode} open={Boolean(characterReferenceNode)} onClose={() => setCharacterReferenceNodeId(null)} />
<CanvasCharacterReferenceModal node={characterReferenceNode} canvasNodes={nodes} open={Boolean(characterReferenceNode)} onClose={() => setCharacterReferenceNodeId(null)} onUpdated={(detail) => setNodes((current) => refreshCanvasCharacterReferenceNodes(current, [{ ...detail.asset, character: detail.character }]))} />
<CanvasTextEditorModal
node={textEditorNode}
@@ -3297,6 +3325,17 @@ function InfiniteCanvasPage() {
/>
<AssetPickerModal open={assetPickerOpen} multiple={assetInsertScope === "canvas"} onInsert={handleLibraryAssetsInsert} onClose={closeAssetPicker} />
<CanvasCharacterLibraryModal
open={characterLibraryOpen}
imageResourceId={resourceIdFromStorageKey(selectedCharacterImage?.metadata?.storageKey) || undefined}
audioResourceId={resourceIdFromStorageKey(selectedCharacterAudio?.metadata?.storageKey) || undefined}
imageTitle={selectedCharacterImage?.title}
audioTitle={selectedCharacterAudio?.title}
onClose={() => setCharacterLibraryOpen(false)}
onInsert={async (payloads) => {
await handleProjectAssetsInsert(payloads, characterLibraryPosition);
}}
/>
<CanvasProjectAssetModal
open={projectAssetOpen}
detail={linkedProjectQuery.data}
@@ -8,7 +8,8 @@ import { getNodeSpec } from "@/constant/canvas";
import { batchSourceRestriction, buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections, type CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { batchReferenceHandleAtY } from "@/lib/canvas/canvas-batch-table";
import { connectedNodeCenterFromEdgeDrop } from "@/lib/canvas/canvas-connected-node-placement";
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
import { canvasConnectionError, connectionInputSummary } from "@/lib/canvas/canvas-connection-policy";
import { audioModelForConnectionInput } from "@/lib/model-selection";
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
@@ -293,6 +294,12 @@ export function useCanvasConnectionController({
? pending.position
: connectedNodeCenterFromEdgeDrop(pending.position, spec, pending.connection.handleType);
const newNode = createCanvasNode(nodeType, position, metadata);
if (nodeType === CanvasNodeType.Audio) {
const probe = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
const input = probe ? connectionInputSummary(newNode.id, [...nodesRef.current, newNode], connectionsRef.current, probe) : undefined;
const model = input ? audioModelForConnectionInput(config, input) : audioModelForConnectionInput(config, { textCount: 0, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 });
if (model) newNode.metadata = { ...newNode.metadata, model, generationMode: "audio" };
}
if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = "RunningHub 工作流";
if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`;
if (batchSourceNodeIds.length && nodeType === CanvasNodeType.Drawing) {
@@ -405,10 +412,15 @@ export function useCanvasConnectionController({
return plan.connections.length ? "" : plan.skipped[0]?.reason || "当前选中的节点不能连接到此类型";
}
const spec = getNodeSpec(nodeType);
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type: nodeType, title: "", position: pending.position, width: spec.width, height: spec.height };
const pendingNodes = [...nodesRef.current, pendingNode];
const connection = normalizeConnection(pending.connection.nodeId, pendingNode.id, pendingNodes, pending.connection.handleType);
const basePendingNode: CanvasNodeData = { id: "__pending-connection-node__", type: nodeType, title: "", position: pending.position, width: spec.width, height: spec.height };
const connection = normalizeConnection(pending.connection.nodeId, basePendingNode.id, [...nodesRef.current, basePendingNode], pending.connection.handleType);
if (!connection) return "当前节点类型不能这样连接";
// 新建音频节点还没有模型,按这条连线的输入挑一个接得住的音频模型再校验。
const pendingAudioModel = nodeType === CanvasNodeType.Audio
? audioModelForConnectionInput(config, connectionInputSummary(basePendingNode.id, [...nodesRef.current, basePendingNode], connectionsRef.current, connection))
: "";
const pendingNode: CanvasNodeData = pendingAudioModel ? { ...basePendingNode, metadata: { model: pendingAudioModel, generationMode: "audio" } } : basePendingNode;
const pendingNodes = [...nodesRef.current, pendingNode];
return canvasConnectionError(config, pendingNodes, connectionsRef.current, connection);
}, [config, connectionsRef, nodesRef, runtimeStatuses]);
+3 -2
View File
@@ -649,8 +649,9 @@ export function useCanvasUpload({
const createAssetPayloadNode = useCallback(async (payload: InsertAssetPayload, center: Position) => {
if (payload.kind === "character") {
const width = 320;
const height = 260;
// 角色卡是竖版海报式主视觉,默认用 3:4 竖幅。
const width = 264;
const height = 352;
return {
id: `character-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: CanvasNodeType.Text,