fix(canvas): 角色三视图 - 隔离并发状态并恢复刷新后任务结果
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useMutationState, useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd";
|
||||
import { Box, Check, ChevronDown, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
confirmProjectAssetCandidate,
|
||||
createProjectAssetVersion,
|
||||
createProjectCharacter,
|
||||
getProjectCharacter,
|
||||
linkProjectAsset,
|
||||
listVoiceProfiles,
|
||||
replaceProjectCharacterRepresentations,
|
||||
@@ -66,6 +67,11 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const visibleAssets = useMemo(() => category === "all" ? detail.assets : detail.assets.filter((asset) => asset.category === category), [category, detail.assets]);
|
||||
const categoryCounts = categories.map((value) => ({ value, count: value === "all" ? detail.assets.length : detail.assets.filter((asset) => asset.category === value).length }));
|
||||
const voices = useQuery({ queryKey: ["voice-profiles"], queryFn: listVoiceProfiles, enabled: Boolean(voiceAsset) });
|
||||
const generatingAssets = useMutationState({
|
||||
filters: { mutationKey: ["project-character-turnaround", detail.project.id], status: "pending" },
|
||||
select: (mutation) => mutation.state.variables as ProjectAsset | undefined,
|
||||
});
|
||||
const generatingAssetIds = new Set(generatingAssets.map((asset) => asset?.id).filter((id): id is string => Boolean(id)));
|
||||
|
||||
const done = (content: string) => { refreshProject(); message.success(content); };
|
||||
const failed = (fallback: string) => (error: unknown) => message.error(error instanceof Error ? error.message : fallback);
|
||||
@@ -87,13 +93,14 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
onError: failed("角色保存失败"),
|
||||
});
|
||||
const generateMutation = useMutation({
|
||||
mutationKey: ["project-character-turnaround", detail.project.id],
|
||||
mutationFn: async (asset: ProjectAsset) => {
|
||||
if (!asset.character) throw new Error("角色版本信息不完整");
|
||||
const model = effectiveConfig.imageModel || effectiveConfig.model;
|
||||
const config = { ...effectiveConfig, model };
|
||||
if (!isAiConfigReady(config, model)) throw new Error("请先在设置中配置可用的图片模型");
|
||||
const representations = await generateCharacterTurnaround({ projectId: detail.project.id, assetId: asset.id, versionId: asset.character.versionId, name: asset.title, definition: asset.character.definition, config });
|
||||
return replaceProjectCharacterRepresentations(detail.project.id, asset.id, representations);
|
||||
await generateCharacterTurnaround({ projectId: detail.project.id, assetId: asset.id, versionId: asset.character.versionId, name: asset.title, definition: asset.character.definition, config });
|
||||
return getProjectCharacter(detail.project.id, asset.id);
|
||||
},
|
||||
onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); done("三视图已生成并绑定到新角色版本"); },
|
||||
onError: failed("三视图生成失败"),
|
||||
@@ -149,7 +156,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<div className="min-w-0">
|
||||
{(category === "all" || category === "character") && pendingCandidates.length ? <section className="mb-4"><div className="mb-2 flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-[var(--workspace-accent)]" />剧情识别出的角色</div><div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">{pendingCandidates.map((candidate) => <article key={candidate.id} className="flex min-h-28 items-center gap-3 rounded-lg border border-dashed border-border p-3"><span className="grid size-12 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/25"><UserRound className="size-5" /></span><div className="min-w-0 flex-1"><div className="truncate text-xs font-semibold">{candidate.name}</div><div className="mt-1 text-[10px] text-foreground/42">待确认角色卡 · 来自章节分析</div><div className="mt-2 flex min-w-0 items-center gap-1"><Button type="text" size="small" icon={<Check className="size-3.5" />} loading={confirmMutation.isPending} onClick={() => confirmMutation.mutate({ candidateId: candidate.id })}>确认新角色</Button>{characterAssets.length ? <Dropdown trigger={["click"]} menu={{ items: characterAssets.map((asset) => ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}><Button type="text" size="small" disabled={confirmMutation.isPending}>归并到角色<ChevronDown className="size-3" /></Button></Dropdown> : null}</div></div></article>)}</div></section> : null}
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{category === "all" ? "全部资产" : categoryLabel(category)}</span><span>{visibleAssets.length} 项</span></div>
|
||||
{visibleAssets.length ? <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generateMutation.isPending && generateMutation.variables?.id === asset.id} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
|
||||
{visibleAssets.length ? <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@ export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGe
|
||||
const appearance = textValue(character?.definition.appearance) || textValue(character?.definition.consistencyPrompt) || "角色设定待完善";
|
||||
const imageStatus = character?.visualStatus === "ready" ? "图片已绑定" : character?.visualStatus === "partial" ? "图片待补全" : "图片未绑定";
|
||||
const voiceStatus = character?.voiceStatus === "ready" ? `声音已绑定 · ${character.voice?.profile.name}` : character?.voiceStatus === "unavailable" ? "声音需处理" : "声音未绑定";
|
||||
const ready = character?.visualStatus === "ready" && character.voiceStatus === "ready";
|
||||
const readinessLabel = character?.visualStatus === "ready" ? character.voiceStatus === "ready" ? "可直接用于生成" : "形象已就绪" : "设定进行中";
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-lg border border-border/80 bg-background shadow-[0_10px_30px_rgba(0,0,0,.04)]">
|
||||
<div className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
|
||||
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
|
||||
<div className="absolute inset-x-2 top-2 flex items-center justify-between gap-2">
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] font-medium text-white">角色卡 · v{character?.version || 1}</span>
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] text-white">{ready ? "可直接用于生成" : "设定进行中"}</span>
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] text-white">{readinessLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export async function generateCharacterTurnaround(input: { projectId: string; assetId: string; versionId: string; name: string; definition: Record<string, unknown>; config: AiConfig }) {
|
||||
const prompt = characterTurnaroundPrompt(input.name, input.definition);
|
||||
const result = await runBackendCanvasGenerationTask({
|
||||
await runBackendCanvasGenerationTask({
|
||||
projectId: input.projectId,
|
||||
nodeId: `character-turnaround:${input.assetId}`,
|
||||
mode: "image",
|
||||
@@ -13,17 +11,6 @@ export async function generateCharacterTurnaround(input: { projectId: string; as
|
||||
config: { ...input.config, model: input.config.imageModel || input.config.model, count: "1" },
|
||||
metadata: { operation: "character_turnaround", characterAssetId: input.assetId, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
|
||||
});
|
||||
const image = result.images?.[0];
|
||||
if (!image?.dataUrl) throw new Error("三视图任务没有返回图片");
|
||||
const sheet = image.storageKey
|
||||
? { url: image.dataUrl, storageKey: image.storageKey }
|
||||
: await uploadImage(image.dataUrl);
|
||||
const sheetResourceId = resourceIdFromStorageKey(sheet.storageKey);
|
||||
if (!sheetResourceId) throw new Error("三视图原图尚未保存到后端资源库");
|
||||
return [
|
||||
{ role: "turnaround_sheet", resourceId: sheetResourceId, metadata: { prompt } },
|
||||
{ role: "primary", resourceId: sheetResourceId, metadata: { source: "turnaround_sheet" } },
|
||||
];
|
||||
}
|
||||
|
||||
export function characterTurnaroundPrompt(name: string, definition: Record<string, unknown>) {
|
||||
|
||||
Reference in New Issue
Block a user