feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user