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

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

View File

@@ -1,5 +1,8 @@
import { useMemo } from "react";
import { projectCharacterToInsertPayload } from "@/components/canvas/canvas-project-asset-modal";
import { getCharacter } from "@/services/api/projects";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { ASSET_CATEGORY_LABELS, normalizeAssetCategory } from "@/lib/asset-category";
@@ -62,6 +65,8 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
return (
<AssetLibraryPickerModal
remoteLibrary
allowCharacters
acceptRemoteItems
open={open}
mediaKinds={["image", "video", "audio", "text"]}
items={items}
@@ -70,16 +75,31 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
footerNote={externalAssetSources.error || undefined}
multiple={multiple}
confirmLabel={(count) => `插入已选素材${count ? `(${count})` : ""}`}
emptyDescription="先在素材库中添加图片、视频、音频或文本。"
emptyDescription="先在素材库中添加图片、视频、音频、文本或角色卡。"
onClose={onClose}
onConfirm={async (ids) => {
await onInsert(assetPickerItemsToInsertPayloads(ids, items));
onConfirm={async (ids, pickedItems) => {
await onInsert(await assetPickerSelectionToInsertPayloads(ids, pickedItems || items));
onClose();
}}
/>
);
}
/** 选择结果转画布插入:角色卡需要读一次当前版本(形象、声音、设定),其它素材同步转换。 */
export async function assetPickerSelectionToInsertPayloads(ids: string[], items: AssetLibraryPickerItem[]): Promise<InsertAssetPayload[]> {
const itemsById = new Map(items.map((item) => [item.id, item]));
return Promise.all(
ids.map(async (id) => {
const item = itemsById.get(id);
if (item?.asset?.kind === "entity") {
const detail = await getCharacter(item.asset.id);
return projectCharacterToInsertPayload({ ...detail.asset, character: detail.character });
}
return assetPickerItemsToInsertPayloads([id], items)[0];
}),
);
}
export function assetPickerItemsToInsertPayloads(ids: string[], items: AssetLibraryPickerItem[]): InsertAssetPayload[] {
const itemsById = new Map(items.map((item) => [item.id, item]));
return ids.map((id) => {

View File

@@ -4,12 +4,12 @@ import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { AudioSettingsPanel } from "@/components/audio-settings-panel";
import { audioFormatLabel, audioSpeedLabel, audioVoiceLabel } from "@/lib/audio-generation";
import { audioFormatLabelForConfig, audioSpeedLabel, audioVoiceLabelForConfig } from "@/lib/audio-generation";
import { canvasThemes } from "@/lib/canvas-theme";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
export type CanvasAudioSettingKey = "audioVoice" | "audioFormat" | "audioSpeed" | "audioInstructions" | "audioEmotionControlMethod" | "audioEmotionRandom" | "audioEmotionHappy" | "audioEmotionAngry" | "audioEmotionSad" | "audioEmotionAfraid" | "audioEmotionDisgusted" | "audioEmotionMelancholic" | "audioEmotionSurprised" | "audioEmotionCalm";
export type CanvasAudioSettingKey = "audioVoice" | "audioFormat" | "audioSpeed" | "audioLanguage" | "audioDialect" | "audioInstructions" | "audioEmotionControlMethod" | "audioEmotionRandom" | "audioEmotionHappy" | "audioEmotionAngry" | "audioEmotionSad" | "audioEmotionAfraid" | "audioEmotionDisgusted" | "audioEmotionMelancholic" | "audioEmotionSurprised" | "audioEmotionCalm";
type CanvasAudioSettingsPopoverProps = {
config: AiConfig;
@@ -24,7 +24,7 @@ export function CanvasAudioSettingsPopover({ config, onConfigChange, buttonClass
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const summary = `${audioVoiceLabel(config.audioVoice)} · ${audioFormatLabel(config.audioFormat)} · ${audioSpeedLabel(config.audioSpeed)}`;
const summary = `${audioVoiceLabelForConfig(config, config.audioVoice) || "不指定音色"} · ${audioFormatLabelForConfig(config, config.audioFormat)} · ${audioSpeedLabel(config.audioSpeed)}`;
useEffect(() => {
if (!open) return;

View File

@@ -0,0 +1,126 @@
import { App, Button, Input } from "antd";
import { Image as ImageIcon, Plus, Search, UserRound, Volume2 } from "lucide-react";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { AppModal } from "@/components/ui/product/app-modal";
import { PaginationBar } from "@/components/layout/workspace-page";
import { type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
import { projectCharacterToInsertPayload } from "@/components/canvas/canvas-project-asset-modal";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { createCharacter, listCharacters, type ProjectAsset } from "@/services/api/projects";
const CHARACTER_PAGE_SIZE = 12;
export function CanvasCharacterLibraryModal({
open,
imageResourceId,
audioResourceId,
imageTitle,
audioTitle,
onClose,
onInsert,
}: {
open: boolean;
imageResourceId?: string;
audioResourceId?: string;
imageTitle?: string;
audioTitle?: string;
onClose: () => void;
onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void;
}) {
const { message } = App.useApp();
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(CHARACTER_PAGE_SIZE);
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebouncedValue(keyword.trim(), 250);
useEffect(() => {
setPage(1);
}, [debouncedKeyword, pageSize]);
useEffect(() => {
if (!open) return;
setPage(1);
setKeyword("");
setName("");
}, [open]);
const charactersQuery = useQuery({
queryKey: ["characters", "library", page, pageSize, debouncedKeyword],
queryFn: () => listCharacters({ page, pageSize, query: debouncedKeyword || undefined }),
enabled: open,
placeholderData: keepPreviousData,
});
const characters = charactersQuery.data?.characters || [];
const total = charactersQuery.data?.total || 0;
const canPack = Boolean(imageResourceId);
const insertCharacter = async (asset: ProjectAsset) => {
await onInsert([projectCharacterToInsertPayload(asset)]);
onClose();
};
const createAndInsert = async () => {
const title = name.trim() || imageTitle || "未命名角色";
setCreating(true);
try {
const created = await createCharacter({
name: title,
imageResourceId,
audioResourceId,
voiceName: audioTitle,
});
await insertCharacter(created.asset);
setName("");
message.success(canPack ? "已把图片和音频打包成角色卡" : "角色卡已创建");
} catch (error) {
message.error(error instanceof Error ? error.message : "角色卡创建失败");
} finally {
setCreating(false);
}
};
return (
<AppModal open={open} title="角色卡" okButtonProps={{ style: { display: "none" } }} cancelText="关闭" onCancel={onClose} destroyOnHidden width={720}>
<div className="space-y-4">
<div className="rounded-md border border-border bg-foreground/[.03] p-3">
<div className="text-sm font-medium">新建角色卡</div>
<p className="mt-1 text-xs text-foreground/55">
{canPack
? `将使用当前图片${imageTitle ? `「${imageTitle}」` : ""}${audioResourceId ? `和音频${audioTitle ? `「${audioTitle}」` : ""}` : ""}打包。`
: "也可以先建一张空角色卡,之后再绑定形象和声音。选中一张图片,可同时选一段音频后再打开这里打包。"}
</p>
<div className="mt-3 flex gap-2">
<Input value={name} placeholder="角色名称" maxLength={80} onChange={(event) => setName(event.target.value)} onPressEnter={() => void createAndInsert()} />
<Button type="primary" icon={<Plus className="size-4" />} loading={creating} onClick={() => void createAndInsert()}>
{canPack ? "打包创建" : "创建"}
</Button>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs font-medium text-foreground/55">已有角色卡</div>
<Input allowClear value={keyword} prefix={<Search className="size-3.5 text-foreground/40" />} placeholder="搜索角色名称" className="max-w-56" onChange={(event) => setKeyword(event.target.value)} />
</div>
{charactersQuery.isLoading ? <p className="text-sm text-foreground/45">正在读取角色卡</p> : null}
{!charactersQuery.isLoading && !characters.length ? <p className="text-sm text-foreground/45">{debouncedKeyword ? "没有匹配的角色卡" : "还没有角色卡。创建后即可连接到视频、音频和 Agent。"}</p> : null}
<div className="grid grid-cols-2 gap-2">
{characters.map((item) => (
<button key={item.asset.id} type="button" className="flex min-h-16 items-center gap-3 rounded-md border border-border px-3 py-2 text-left hover:bg-foreground/[.04]" onClick={() => void insertCharacter(item.asset)}>
<span className="grid size-10 shrink-0 place-items-center rounded bg-foreground/[.06]"><UserRound className="size-4" /></span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">{item.asset.title}</span>
<span className="mt-1 flex gap-2 text-[11px] text-foreground/50">
<span className="inline-flex items-center gap-1"><ImageIcon className="size-3" />{item.character.visualStatus === "ready" ? "形象就绪" : "形象待完善"}</span>
<span className="inline-flex items-center gap-1"><Volume2 className="size-3" />{item.character.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}</span>
</span>
</span>
</button>
))}
</div>
<PaginationBar current={page} pageSize={pageSize} total={total} pageSizeOptions={[12, 24, 48]} itemLabel="张" onChange={(nextPage, nextPageSize) => { setPage(nextPage); setPageSize(nextPageSize); }} />
</div>
</div>
</AppModal>
);
}

View File

@@ -1,135 +1,313 @@
import { App, Button, Input } from "antd";
import { AudioLines, IdCard, ImageIcon, Mic, Pause, Play } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AudioLines, BadgeCheck, Image as ImageIcon, UserRound, Volume2 } from "lucide-react";
import { AppModal } from "@/components/ui/product/app-modal";
import type { ReactNode } from "react";
import { CharacterImageLibrary, CharacterVoiceLibrary, type CanvasMediaTitles } from "@/components/canvas/character-media-library";
import { characterCoverResourceId, characterInitial, useCharacterCoverSrc } from "@/components/canvas/canvas-character-reference-node";
import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { bindCharacterVoice, getCharacter, replaceCharacterRepresentations, unbindCharacterVoice, updateCharacter, type ProjectCharacterDetail } from "@/services/api/projects";
import type { CanvasNodeData } from "@/types/canvas";
export function CanvasCharacterReferenceModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) {
if (!node) return null;
const metadata = node.metadata;
const definition = metadata?.characterDefinition || {};
const name = metadata?.characterName || node.title || "未命名角色";
const aliases = metadata?.characterAliases || stringList(definition.aliases);
const visualReady = metadata?.characterVisualStatus === "ready";
const voiceReady = metadata?.characterVoiceStatus === "ready";
const voiceProfile = metadata?.characterVoiceProfile;
type Tab = "profile" | "visual" | "voice";
type FieldKey = "role" | "aliases" | "appearance" | "physique" | "clothing" | "personality" | "props" | "consistencyPrompt" | "multiViewPrompt" | "voiceLanguage" | "voiceAge" | "voiceTimbre";
type Drafts = Record<FieldKey | "name", string>;
const FIELD_GROUPS: Array<{ title: string; fields: Array<{ key: FieldKey; label: string; placeholder: string; wide?: boolean }> }> = [
{ title: "身份", fields: [
{ key: "role", label: "剧情身份", placeholder: "例如:落魄剑客,主角的师兄" },
{ key: "aliases", label: "别名", placeholder: "多个别名用逗号分隔" },
{ key: "personality", label: "性格气质", placeholder: "冷静、克制、偶尔自嘲", wide: true },
] },
{ title: "外观", fields: [
{ key: "appearance", label: "外貌特征", placeholder: "脸型、五官、发型、年龄感", wide: true },
{ key: "physique", label: "体型姿态", placeholder: "身高、体态、习惯动作" },
{ key: "clothing", label: "服装造型", placeholder: "主服装与配色" },
{ key: "props", label: "标志道具", placeholder: "随身物品或武器", wide: true },
{ key: "consistencyPrompt", label: "一致性要求", placeholder: "生成时必须保持不变的特征", wide: true },
{ key: "multiViewPrompt", label: "多视图要求", placeholder: "三视图 / 多角度生成补充说明", wide: true },
] },
{ title: "声音画像", fields: [
{ key: "voiceLanguage", label: "语言口音", placeholder: "普通话,略带川音" },
{ key: "voiceAge", label: "年龄感", placeholder: "青年" },
{ key: "voiceTimbre", label: "音色气质", placeholder: "低沉、沙哑、语速偏慢", wide: true },
] },
];
const FIELD_KEYS = FIELD_GROUPS.flatMap((group) => group.fields.map((field) => field.key));
function draftsFrom(name: string, definition: Record<string, unknown>): Drafts {
const drafts = { name } as Drafts;
FIELD_KEYS.forEach((key) => {
const value = definition[key];
drafts[key] = key === "aliases" ? (Array.isArray(value) ? value.filter((item) => typeof item === "string").join(",") : "") : typeof value === "string" ? value : "";
});
return drafts;
}
function coverOf(detail?: ProjectCharacterDetail) {
const items = detail?.character.representations || [];
return (items.find((item) => item.role === "turnaround_sheet") || items.find((item) => item.role === "primary") || items.find((item) => item.role === "front"))?.resourceId || "";
}
export function CanvasCharacterReferenceModal({ node, canvasNodes, open, onClose, onUpdated }: { node: CanvasNodeData | null; canvasNodes?: CanvasNodeData[]; open: boolean; onClose: () => void; onUpdated?: (detail: ProjectCharacterDetail) => void }) {
// 资源 ID → 画布节点名:用户在画布上重命名过的图片/音频,选择时按新名字显示并作为声音名保存。
const canvasTitles = useMemo<CanvasMediaTitles>(() => {
const titles = new Map<string, string>();
for (const item of canvasNodes || []) {
const resourceId = resourceIdFromStorageKey(item.metadata?.storageKey);
const title = item.title?.trim();
if (resourceId && title && !titles.has(resourceId)) titles.set(resourceId, title);
}
return titles;
}, [canvasNodes]);
return (
<AppModal
open={open}
title={null}
footer={null}
destroyOnHidden
width="min(1180px, calc(100vw - 32px))"
onCancel={onClose}
flush
>
<div className="grid h-[min(720px,calc(100dvh-48px))] min-h-0 grid-rows-[minmax(240px,42vh)_minmax(0,1fr)] overflow-hidden bg-background text-foreground md:grid-cols-[minmax(0,1.55fr)_minmax(360px,.85fr)] md:grid-rows-1">
<section className="relative min-h-0 overflow-hidden border-b border-border bg-foreground/[.035] md:border-b-0 md:border-r" aria-label="角色三视图">
<div className="absolute inset-x-0 top-0 z-10 flex h-14 items-center justify-between px-5 pr-14">
<span className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border/80 bg-background/80 px-2.5 text-[var(--fs-label)] font-medium shadow-sm backdrop-blur-xl">
<ImageIcon className="size-3.5" />
人物三视图
</span>
<StatusDot icon={<ImageIcon />} ready={visualReady} readyLabel="形象已绑定" emptyLabel="形象待完善" />
</div>
{metadata?.characterCoverUrl ? (
<div className="flex h-full w-full items-center justify-center p-5 pt-16 md:p-8 md:pt-20">
<img
src={metadata.characterCoverUrl}
alt={`${name}人物三视图`}
className="max-h-full max-w-full select-none object-contain drop-shadow-[0_22px_42px_rgba(0,0,0,.14)]"
draggable={false}
/>
</div>
) : (
<div className="grid h-full place-items-center px-8 text-center text-foreground/35">
<div>
<UserRound className="mx-auto size-14 stroke-[1.25]" />
<p className="mt-3 text-xs">尚未绑定人物三视图</p>
</div>
</div>
)}
</section>
<aside className="thin-scrollbar min-h-0 overflow-y-auto">
<header className="border-b border-border px-6 pb-5 pt-7 pr-14">
<div className="flex items-start gap-3">
<span className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-md bg-foreground/[.07]">
<UserRound className="size-4.5" />
</span>
<div className="min-w-0">
<h2 className="truncate text-xl font-semibold leading-7">{name}</h2>
<p className="mt-1 text-[var(--fs-label)] text-foreground/45">项目角色引用 · 当前版本</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-1.5">
<StatusDot icon={<ImageIcon />} ready={visualReady} readyLabel="图片已绑定" emptyLabel="图片未绑定" />
<StatusDot icon={<Volume2 />} ready={voiceReady} readyLabel="声音已绑定" emptyLabel="声音未绑定" />
</div>
</header>
<div className="space-y-7 px-6 py-6">
<DetailSection icon={<BadgeCheck />} title="身份与外观">
<DetailRow label="剧情身份" value={stringValue(definition.role)} />
<DetailRow label="别名" value={aliases.join("、")} />
<DetailRow label="外貌特征" value={stringValue(definition.appearance)} />
<DetailRow label="体型姿态" value={stringValue(definition.physique)} />
<DetailRow label="服装造型" value={stringValue(definition.clothing)} />
<DetailRow label="性格气质" value={stringValue(definition.personality)} />
<DetailRow label="标志道具" value={stringValue(definition.props)} />
<DetailRow label="一致性要求" value={stringValue(definition.consistencyPrompt)} />
</DetailSection>
<DetailSection icon={<AudioLines />} title="音色">
<DetailRow label="语言口音" value={stringValue(definition.voiceLanguage) || voiceProfile?.language} />
<DetailRow label="声音年龄感" value={stringValue(definition.voiceAge)} />
<DetailRow label="音色气质" value={stringValue(definition.voiceTimbre) || voiceProfile?.timbre} />
<DetailRow label="绑定声音" value={metadata?.characterVoiceName} emphasis={voiceReady} />
<DetailRow label="朗读要求" value={metadata?.characterVoiceInstructions} />
</DetailSection>
</div>
</aside>
</div>
<AppModal open={open && Boolean(node)} title={null} footer={null} destroyOnHidden width="min(1120px, calc(100vw - 32px))" onCancel={onClose} flush>
{node?.metadata?.characterAssetId ? <CharacterStudio key={node.id} node={node} assetId={node.metadata.characterAssetId} canvasTitles={canvasTitles} onUpdated={onUpdated} /> : null}
</AppModal>
);
}
function DetailSection({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
return (
<section>
<div className="mb-3 flex items-center gap-2 text-xs font-semibold">
<span className="grid size-6 place-items-center rounded bg-foreground/[.06] [&_svg]:size-3.5">{icon}</span>
{title}
</div>
<dl className="divide-y divide-border/65 border-y border-border/65">{children}</dl>
</section>
);
}
function CharacterStudio({ node, assetId, canvasTitles, onUpdated }: { node: CanvasNodeData; assetId: string; canvasTitles: CanvasMediaTitles; onUpdated?: (detail: ProjectCharacterDetail) => void }) {
const { message } = App.useApp();
const queryClient = useQueryClient();
const detailQuery = useQuery({ queryKey: ["character-studio", assetId], queryFn: () => getCharacter(assetId) });
const detail = detailQuery.data;
const metadata = node.metadata;
const name = detail?.asset.title || metadata?.characterName || node.title || "未命名角色";
const definition = detail?.character.definition || metadata?.characterDefinition || {};
// 以序列化结果作依赖,避免每次渲染的新对象反复重置草稿。
const definitionKey = JSON.stringify(definition);
const baseline = useMemo(() => draftsFrom(name, JSON.parse(definitionKey) as Record<string, unknown>), [name, definitionKey]);
const [drafts, setDrafts] = useState<Drafts>(baseline);
const [tab, setTab] = useState<Tab>("profile");
const [busyKey, setBusyKey] = useState("");
// 服务端数据到达或保存成功后,用新基线覆盖草稿。
useEffect(() => setDrafts(baseline), [baseline]);
const dirty = (Object.keys(baseline) as Array<keyof Drafts>).some((key) => baseline[key].trim() !== drafts[key].trim());
const coverResourceId = detail ? coverOf(detail) : characterCoverResourceId(metadata?.characterCoverUrl);
const cover = useCharacterCoverSrc(coverResourceId ? resourceFileUrl(coverResourceId) : undefined);
const voice = detail?.character.voice;
const voiceSampleId = voice?.profile.sampleResourceId || "";
// 已绑定的样本如果在画布上被重命名,展示时跟随画布名;重新绑定时会把新名字写回声音档案。
const voiceName = (voiceSampleId && canvasTitles.get(voiceSampleId)) || voice?.profile.name || metadata?.characterVoiceName || "";
const commit = async (key: string, work: () => Promise<ProjectCharacterDetail>, success: string) => {
setBusyKey(key);
try {
const next = await work();
queryClient.setQueryData(["character-studio", assetId], next);
void queryClient.invalidateQueries({ queryKey: ["characters"] });
onUpdated?.(next);
message.success(success);
} catch (error) {
message.error(error instanceof Error ? error.message : "角色卡保存失败,请稍后重试");
} finally {
setBusyKey("");
}
};
const bindImage = (resourceId: string) => commit(resourceId, () => replaceCharacterRepresentations(assetId, [{ role: "turnaround_sheet", resourceId }, { role: "primary", resourceId }]), "形象已更新");
const bindVoice = (resourceId: string, title: string) => commit(resourceId, () => bindCharacterVoice(assetId, { sampleResourceId: resourceId, voiceName: title }), "声音已绑定");
const upload = (file: File, kind: "image" | "audio") => commit("upload", async () => {
const resource = await uploadResourceFile(file, kind, { fileName: file.name });
return kind === "image"
? replaceCharacterRepresentations(assetId, [{ role: "turnaround_sheet", resourceId: resource.id }, { role: "primary", resourceId: resource.id }])
: bindCharacterVoice(assetId, { sampleResourceId: resource.id, voiceName: file.name.replace(/\.[^.]+$/, "") });
}, kind === "image" ? "形象已更新" : "声音已绑定");
const saveProfile = () => {
if (!drafts.name.trim()) {
message.error("角色名称不能为空");
return;
}
const nextDefinition: Record<string, unknown> = { ...definition };
FIELD_KEYS.forEach((key) => {
nextDefinition[key] = key === "aliases" ? drafts.aliases.split(/[,,、]/).map((item) => item.trim()).filter(Boolean) : drafts[key].trim();
});
void commit("profile", () => updateCharacter(assetId, { name: drafts.name.trim(), definition: nextDefinition }), "角色资料已保存");
};
function DetailRow({ label, value, emphasis = false }: { label: string; value?: string; emphasis?: boolean }) {
return (
<div className="grid grid-cols-[76px_minmax(0,1fr)] gap-3 py-3 text-[var(--fs-caption)] leading-5">
<dt className="text-foreground/42">{label}</dt>
<dd className={value ? (emphasis ? "font-medium text-emerald-600 dark:text-emerald-300" : "text-foreground/78") : "text-foreground/28"}>{value || "未设置"}</dd>
<div className="grid h-[min(760px,calc(100dvh-48px))] min-h-0 grid-rows-[minmax(260px,40vh)_minmax(0,1fr)] overflow-hidden bg-background text-foreground md:grid-cols-[minmax(0,1fr)_minmax(420px,1.05fr)] md:grid-rows-1">
<CharacterStage name={drafts.name.trim() || name} role={drafts.role.trim()} cover={cover} hasCover={Boolean(coverResourceId)} voiceName={voiceName} voiceSampleId={voiceSampleId} version={detail?.character.version} />
<section className="flex min-h-0 flex-col">
<CharacterTabs value={tab} onChange={setTab} />
<div id={`character-tab-panel-${tab}`} role="tabpanel" aria-labelledby={`character-tab-${tab}`} className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-7 pb-8 pt-6">
{tab === "profile" ? <ProfileForm drafts={drafts} onChange={(key, value) => setDrafts((current) => ({ ...current, [key]: value }))} /> : null}
{tab === "visual" ? <CharacterImageLibrary currentResourceId={coverResourceId} busyKey={busyKey} canvasTitles={canvasTitles} onPick={(id) => void bindImage(id)} onUpload={(file) => void upload(file, "image")} /> : null}
{tab === "voice" ? (
<div className="space-y-5">
{voiceName ? (
<div className="flex items-center justify-between gap-3 rounded-lg bg-foreground/[.04] px-4 py-3">
<span className="min-w-0">
<span className="block text-[11px] text-foreground/45">当前声音</span>
<span className="block truncate text-sm font-medium">{voiceName}</span>
</span>
<button type="button" disabled={Boolean(busyKey)} onClick={() => void commit("unbind", () => unbindCharacterVoice(assetId), "声音绑定已解除")} className="shrink-0 rounded text-xs text-foreground/45 underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] disabled:cursor-wait">
解除绑定
</button>
</div>
) : null}
<CharacterVoiceLibrary currentResourceId={voiceSampleId} busyKey={busyKey} canvasTitles={canvasTitles} onPick={(id, title) => void bindVoice(id, title)} onUpload={(file) => void upload(file, "audio")} />
</div>
) : null}
</div>
<footer className={`grid shrink-0 transition-[grid-template-rows] duration-200 [transition-timing-function:cubic-bezier(.16,1,.3,1)] motion-reduce:transition-none ${dirty ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`} aria-hidden={!dirty}>
<div className="overflow-hidden">
<div className="flex items-center justify-between gap-3 border-t border-border px-7 py-3.5">
<span className="text-xs text-foreground/45">资料有改动,保存后生成新版本</span>
<span className="flex gap-2">
<Button type="text" size="small" tabIndex={dirty ? 0 : -1} onClick={() => setDrafts(baseline)}>还原</Button>
<Button type="primary" size="small" tabIndex={dirty ? 0 : -1} loading={busyKey === "profile"} onClick={saveProfile}>保存资料</Button>
</span>
</div>
</div>
</footer>
</section>
</div>
);
}
function StatusDot({ icon, ready, readyLabel, emptyLabel }: { icon: ReactNode; ready: boolean; readyLabel: string; emptyLabel: string }) {
const TABS: Array<{ value: Tab; label: string; icon: ReactNode }> = [
{ value: "profile", label: "资料", icon: <IdCard /> },
{ value: "visual", label: "形象", icon: <ImageIcon /> },
{ value: "voice", label: "声音", icon: <AudioLines /> },
];
/** 右侧主导航:等分整行、带图标和下划线指示,比分段控件更醒目;支持左右方向键切换。 */
function CharacterTabs({ value, onChange }: { value: Tab; onChange: (tab: Tab) => void }) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const index = TABS.findIndex((item) => item.value === value);
const next = (index + (event.key === "ArrowRight" ? 1 : TABS.length - 1)) % TABS.length;
onChange(TABS[next].value);
refs.current[next]?.focus();
};
return (
<span className={`inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[var(--fs-tiny)] font-medium ${ready ? "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "border-border bg-background/65 text-foreground/42"}`}>
<span className="[&_svg]:size-3">{icon}</span>
{ready ? readyLabel : emptyLabel}
</span>
<div role="tablist" aria-label="角色卡设置" onKeyDown={onKeyDown} className="grid shrink-0 grid-cols-3 border-b border-border pl-3 pr-14 pt-3">
{TABS.map((item, index) => {
const active = item.value === value;
return (
<button
key={item.value}
ref={(element) => {
refs.current[index] = element;
}}
id={`character-tab-${item.value}`}
type="button"
role="tab"
aria-selected={active}
aria-controls={`character-tab-panel-${item.value}`}
tabIndex={active ? 0 : -1}
onClick={() => onChange(item.value)}
className={`relative flex h-14 items-center justify-center gap-2 rounded-t-lg text-[15px] outline-none transition-colors duration-150 focus-visible:bg-foreground/[.05] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)] [&_svg]:size-[18px] ${active ? "font-semibold text-foreground" : "font-medium text-foreground/50 hover:bg-foreground/[.03] hover:text-foreground/80"}`}
>
{item.icon}
{item.label}
<span aria-hidden className={`absolute inset-x-5 -bottom-px h-[3px] rounded-full bg-[var(--workspace-accent)] transition-opacity duration-200 ${active ? "opacity-100" : "opacity-0"}`} />
</button>
);
})}
</div>
);
}
function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
/** 左侧主视觉:模糊底 + 全幅立绘,名字压在底部;声音可直接试听。 */
function CharacterStage({ name, role, cover, hasCover, voiceName, voiceSampleId, version }: { name: string; role: string; cover: string; hasCover: boolean; voiceName: string; voiceSampleId: string; version?: number }) {
return (
<section className="relative isolate min-h-0 overflow-hidden bg-[#0f0f12] text-white" aria-label={`${name}角色形象`}>
{cover ? (
<>
<img src={cover} alt="" aria-hidden className="absolute inset-0 -z-10 h-full w-full scale-125 object-cover opacity-45 blur-3xl saturate-125" draggable={false} />
<img src={cover} alt={`${name}角色形象`} className="absolute inset-0 h-full w-full object-contain px-8 pb-32 pt-12 drop-shadow-[0_30px_60px_rgba(0,0,0,.5)]" draggable={false} />
</>
) : (
<div className="absolute inset-0 grid place-items-center bg-[radial-gradient(circle_at_50%_40%,rgba(255,255,255,.08),transparent_62%)] pb-24">
{hasCover ? null : (
<div className="text-center">
<span className="mx-auto grid size-28 place-items-center rounded-full border border-white/12 text-5xl font-extralight text-white/70">{characterInitial(name)}</span>
<p className="mt-4 text-xs text-white/40">在右侧「形象」中选择或上传立绘</p>
</div>
)}
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent" />
<span className="absolute left-7 top-6 text-[10px] font-medium uppercase tracking-[.28em] text-white/50">Character{version ? ` · v${version}` : ""}</span>
<div className="absolute inset-x-0 bottom-0 px-7 pb-7">
<h2 className="truncate text-[32px] font-semibold leading-tight tracking-tight">{name}</h2>
<p className="mt-1 line-clamp-2 max-w-[46ch] text-sm leading-6 text-white/60">{role || "尚未填写剧情身份"}</p>
<VoiceChip name={voiceName} sampleId={voiceSampleId} />
</div>
</section>
);
}
function stringList(value: unknown) {
return Array.isArray(value) ? value.map(stringValue).filter(Boolean) : [];
function VoiceChip({ name, sampleId }: { name: string; sampleId: string }) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
useEffect(() => () => audioRef.current?.pause(), []);
useEffect(() => {
audioRef.current?.pause();
setPlaying(false);
}, [sampleId]);
if (!name) return <p className="mt-4 inline-flex items-center gap-1.5 text-xs text-white/35"><Mic className="size-3.5" />未绑定声音</p>;
const toggle = async () => {
if (!sampleId) return;
const audio = audioRef.current || (audioRef.current = new Audio());
if (playing) {
audio.pause();
setPlaying(false);
return;
}
setPlaying(true);
try {
audio.src = resolveResourceAccessURL((await getResourceAccess(resourceStorageKey(sampleId), "display", "playback")).url);
audio.onended = () => setPlaying(false);
await audio.play();
} catch {
setPlaying(false);
}
};
return (
<button type="button" disabled={!sampleId} onClick={() => void toggle()} aria-label={sampleId ? `${playing ? "暂停" : "试听"}声音 ${name}` : `声音 ${name}`} className="mt-4 inline-flex max-w-full items-center gap-2 rounded-full border border-white/15 bg-white/10 py-1 pl-1 pr-3.5 text-xs text-white/85 outline-none backdrop-blur transition-colors hover:bg-white/15 focus-visible:ring-2 focus-visible:ring-white/60 disabled:cursor-default">
<span className="grid size-6 place-items-center rounded-full bg-white text-black">{playing ? <Pause className="size-3" /> : sampleId ? <Play className="size-3 translate-x-px" /> : <Mic className="size-3" />}</span>
<span className="truncate">{name}</span>
</button>
);
}
function ProfileForm({ drafts, onChange }: { drafts: Drafts; onChange: (key: keyof Drafts, value: string) => void }) {
return (
<div className="space-y-8">
<Field label="角色名称">
<Input variant="filled" size="large" value={drafts.name} maxLength={80} placeholder="角色名称" onChange={(event) => onChange("name", event.target.value)} className="!text-base !font-medium" />
</Field>
{FIELD_GROUPS.map((group) => (
<fieldset key={group.title} className="space-y-4">
<legend className="mb-4 flex w-full items-center gap-3 text-[11px] font-medium uppercase tracking-[.18em] text-foreground/40">
{group.title}
<span className="h-px flex-1 bg-border" />
</legend>
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
{group.fields.map((field) => (
<Field key={field.key} label={field.label} wide={field.wide}>
<Input.TextArea variant="filled" autoSize={{ minRows: 1, maxRows: 6 }} value={drafts[field.key]} placeholder={field.placeholder} onChange={(event) => onChange(field.key, event.target.value)} />
</Field>
))}
</div>
</fieldset>
))}
</div>
);
}
function Field({ label, wide = true, children }: { label: string; wide?: boolean; children: ReactNode }) {
return (
<label className={`block ${wide ? "col-span-2" : "col-span-2 sm:col-span-1"}`}>
<span className="mb-1.5 block text-xs text-foreground/55">{label}</span>
{children}
</label>
);
}

View File

@@ -1,26 +1,89 @@
import { Image as ImageIcon, UserRound, Volume2 } from "lucide-react";
import type { ReactNode } from "react";
import { Image as ImageIcon, Mic } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { getResourceAccess, resolveResourceAccessURL, resourceStorageKey } from "@/services/api/resources";
import type { CanvasNodeData } from "@/types/canvas";
export function CanvasCharacterReferenceNodeContent({ node }: { node: CanvasNodeData }) {
const visualReady = node.metadata?.characterVisualStatus === "ready";
const voiceReady = node.metadata?.characterVoiceStatus === "ready";
return <div className="flex h-full w-full flex-col overflow-hidden bg-background">
<div className="relative min-h-0 flex-1 overflow-hidden bg-foreground/[.045]">
{node.metadata?.characterCoverUrl ? <img src={node.metadata.characterCoverUrl} alt={node.metadata?.characterName || node.title} className="h-full w-full object-contain p-2" draggable={false} /> : <div className="grid h-full place-items-center text-foreground/22"><UserRound className="size-12" /></div>}
<span className="absolute left-3 top-3 rounded bg-black/60 px-2 py-1 text-[var(--fs-tiny)] font-medium text-white">角色引用</span>
</div>
<div className="shrink-0 border-t border-border/70 px-3 py-2.5">
<div className="truncate text-sm font-semibold">{node.metadata?.characterName || node.title}</div>
<div className="mt-2 flex min-w-0 gap-1.5">
<State icon={<ImageIcon className="size-3" />} ready={visualReady} label={visualReady ? "形象就绪" : "形象待完善"} />
<State icon={<Volume2 className="size-3" />} ready={voiceReady} label={voiceReady ? node.metadata?.characterVoiceName || "声音已绑定" : "声音未绑定"} />
</div>
</div>
</div>;
const RESOURCE_FILE_PATTERN = /\/resources\/([^/?#]+)\/file/;
/** 角色卡封面存的是 `/resources/:id/file`,展示前换成授权访问地址;换取失败再回落原地址。 */
export function useCharacterCoverSrc(url?: string) {
const [resolved, setResolved] = useState<{ url: string; src: string } | null>(null);
const resourceId = url ? RESOURCE_FILE_PATTERN.exec(url)?.[1] : undefined;
useEffect(() => {
if (!url || !resourceId) return;
let cancelled = false;
getResourceAccess(resourceStorageKey(decodeURIComponent(resourceId)), "display")
.then((access) => {
if (!cancelled) setResolved({ url, src: resolveResourceAccessURL(access.url) || url });
})
.catch(() => {
if (!cancelled) setResolved({ url, src: url });
});
return () => {
cancelled = true;
};
}, [resourceId, url]);
if (!url) return "";
if (!resourceId) return url;
return resolved?.url === url ? resolved.src : "";
}
function State({ icon, ready, label }: { icon: ReactNode; ready: boolean; label: string }) {
return <span className={`inline-flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-[var(--fs-tiny)] ${ready ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "bg-foreground/[.055] text-foreground/45"}`}>{icon}<span className="truncate">{label}</span></span>;
export function characterCoverResourceId(url?: string) {
const id = url ? RESOURCE_FILE_PATTERN.exec(url)?.[1] : undefined;
return id ? decodeURIComponent(id) : "";
}
export function characterInitial(name: string) {
return Array.from(name.trim())[0] || "角";
}
/** 画布上的角色卡:海报式主视觉,底部压名字与形象/声音状态。 */
export function CanvasCharacterReferenceNodeContent({ node }: { node: CanvasNodeData }) {
const metadata = node.metadata;
const name = metadata?.characterName || node.title || "未命名角色";
const role = typeof metadata?.characterDefinition?.role === "string" ? metadata.characterDefinition.role.trim() : "";
const visualReady = metadata?.characterVisualStatus === "ready";
const voiceReady = metadata?.characterVoiceStatus === "ready";
const cover = useCharacterCoverSrc(metadata?.characterCoverUrl);
return (
<div className="relative isolate h-full w-full overflow-hidden bg-[#111114] text-white">
{cover ? (
<>
<img src={cover} alt="" aria-hidden className="absolute inset-0 -z-10 h-full w-full scale-125 object-cover opacity-50 blur-2xl saturate-125" draggable={false} />
<img src={cover} alt={name} className="absolute inset-x-0 top-0 h-[calc(100%-40px)] w-full object-contain px-3 pt-3 drop-shadow-[0_18px_30px_rgba(0,0,0,.45)]" draggable={false} />
</>
) : (
<div className="absolute inset-0 grid place-items-center bg-[radial-gradient(circle_at_50%_36%,rgba(255,255,255,.1),transparent_60%)] pb-10">
<span className="grid size-20 place-items-center rounded-full border border-white/15 bg-white/[.03] text-3xl font-light text-white/75">{characterInitial(name)}</span>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-3/5 bg-gradient-to-t from-black/90 via-black/45 to-transparent" />
<span className="absolute left-3.5 top-3 text-[10px] font-medium uppercase tracking-[.24em] text-white/55">Character</span>
<div className="absolute inset-x-0 bottom-0 flex items-end gap-3 px-3.5 pb-3">
<div className="min-w-0 flex-1">
<div className="truncate text-[17px] font-semibold leading-6 tracking-tight">{name}</div>
<div className="truncate text-[11px] leading-4 text-white/55">{role || (voiceReady && metadata?.characterVoiceName ? `声音 · ${metadata.characterVoiceName}` : "角色卡")}</div>
</div>
<div className="flex shrink-0 gap-1">
<Indicator ready={visualReady} label={visualReady ? "形象已就绪" : "形象待完善"}>
<ImageIcon className="size-3" />
</Indicator>
<Indicator ready={voiceReady} label={voiceReady ? `声音:${metadata?.characterVoiceName || "已绑定"}` : "声音未绑定"}>
<Mic className="size-3" />
</Indicator>
</div>
</div>
</div>
);
}
function Indicator({ ready, label, children }: { ready: boolean; label: string; children: ReactNode }) {
return (
<span title={label} aria-label={label} className={`relative grid size-6 place-items-center rounded-full border backdrop-blur ${ready ? "border-white/25 bg-white/15 text-white" : "border-white/10 bg-white/[.04] text-white/35"}`}>
{children}
{ready ? <span className="absolute -right-px -top-px size-1.5 rounded-full bg-emerald-400" /> : null}
</span>
);
}

View File

@@ -238,6 +238,8 @@ function escapeAgentNodePattern(value: string) {
}
function agentNodeTypeLabel(nodeId: string, reference?: CanvasResourceReference) {
// 角色卡底层是 text 节点,sourceType 会是 text;按资源 kind 先识别,避免报成"文本节点"。
if (reference?.kind === "character") return "角色卡";
const sourceType = String(reference?.sourceType || "");
if (sourceType && AGENT_NODE_TYPE_LABELS[sourceType]) return AGENT_NODE_TYPE_LABELS[sourceType];
const prefix = AGENT_NODE_TYPE_PREFIXES.find((candidate) => nodeId.startsWith(`${candidate}-`));

View File

@@ -610,9 +610,11 @@ export function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: Re
? "创建分镜"
: item.operation === "edit_storyboard"
? "修改分镜"
: item.operation === "plan_step"
? "计划"
: "生成";
: item.operation === "create_character"
? "创建角色卡"
: item.operation === "plan_step"
? "计划"
: "生成";
const renderNode = (title: string | undefined, id: string | undefined, typeLabel: string | undefined, role: "source" | "target" | "node") => {
if (!title) return null;
const content = (

View File

@@ -90,6 +90,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed,
audioLanguage: node.metadata?.audioLanguage || globalConfig.audioLanguage,
audioDialect: node.metadata?.audioDialect || globalConfig.audioDialect,
}, mode),
};
const config = buildNodeConfig(globalConfig, node, mode, requirements);
@@ -500,6 +502,8 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
audioLanguage: node.metadata?.audioLanguage || globalConfig.audioLanguage || defaultConfig.audioLanguage,
audioDialect: node.metadata?.audioDialect || globalConfig.audioDialect || defaultConfig.audioDialect,
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
audioEmotionControlMethod: node.metadata?.audioEmotionControlMethod || globalConfig.audioEmotionControlMethod || defaultConfig.audioEmotionControlMethod,
audioEmotionRandom: node.metadata?.audioEmotionRandom || globalConfig.audioEmotionRandom || defaultConfig.audioEmotionRandom,
@@ -566,6 +570,8 @@ function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
audioLanguage: node.metadata?.audioLanguage || globalConfig.audioLanguage || defaultConfig.audioLanguage,
audioDialect: node.metadata?.audioDialect || globalConfig.audioDialect || defaultConfig.audioDialect,
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
audioEmotionControlMethod: node.metadata?.audioEmotionControlMethod || globalConfig.audioEmotionControlMethod || defaultConfig.audioEmotionControlMethod,
audioEmotionRandom: node.metadata?.audioEmotionRandom || globalConfig.audioEmotionRandom || defaultConfig.audioEmotionRandom,
@@ -593,6 +599,8 @@ function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
if (key === "audioVoice") return { audioVoice: value };
if (key === "audioFormat") return { audioFormat: value };
if (key === "audioSpeed") return { audioSpeed: value };
if (key === "audioLanguage") return { audioLanguage: value };
if (key === "audioDialect") return { audioDialect: value };
if (key === "audioInstructions") return { audioInstructions: value };
return { [key]: value };
}

View File

@@ -7,7 +7,7 @@ import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type
import { getGenerationResourceNodes, getContextResourceNodes, getMentionResourceNodes } from "@/lib/canvas/canvas-resource-references";
import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { isNeutralColorGrade, resolveCanvasColorGradeReference } from "@/lib/canvas/canvas-color-grade";
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { getNodeGenerationMode, getNodeResourceKind } from "@/lib/canvas/node-registry";
import { mediaConversionSourceFingerprint } from "@/lib/media-conversion/contracts";
import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference";
import { compileCharacterReferencePrompt, normalizeCharacterImageMentions } from "@/lib/canvas/canvas-character-reference";
@@ -87,8 +87,10 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
const hasExplicitResourceMention = hasResolvableGenerationMention(prompt, mentionInputs);
const isWorkflowSource = sourceNode?.type === CanvasNodeType.Config && isCanvasWorkflowProvider(sourceNode.metadata);
const hasConnectedMedia = connectedInputs.some((input) => input.type === "image" || input.type === "video" || input.type === "audio" || input.type === "character");
// 豆包音频按连线决定纯文本、参考音频或参考图片,不能因为提示词里没有逐个 @ 就丢掉已连接素材。
const includeConnectedAudioMedia = Boolean(sourceNode && getNodeGenerationMode(sourceNode) === "audio");
if ((promptOnly && hasConnectedMedia) || (Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) {
const autoIncludeWorkflowMedia = isWorkflowSource;
const autoIncludeWorkflowMedia = isWorkflowSource || includeConnectedAudioMedia;
return buildComposerGenerationContext(
mentionInputs,
prompt,
@@ -493,10 +495,9 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
}),
);
if (!context.characterReferences.length) return { ...context, referenceImages };
if (!domainProjectId) throw new Error("角色引用未关联短剧项目,无法解析角色版本");
const { getProjectCharacter } = await import("@/services/api/projects");
const { getCharacter } = await import("@/services/api/projects");
const { getResource, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
const details = await Promise.all(context.characterReferences.map((reference) => getProjectCharacter(domainProjectId, reference.assetId)));
const details = await Promise.all(context.characterReferences.map((reference) => getCharacter(reference.assetId)));
const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length);
const selected = details.flatMap((detail) => {
const representation = preferredCharacterRepresentation(detail.character.representations);

View File

@@ -70,6 +70,8 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
audioLanguage: node.metadata?.audioLanguage || globalConfig.audioLanguage || defaultConfig.audioLanguage,
audioDialect: node.metadata?.audioDialect || globalConfig.audioDialect || defaultConfig.audioDialect,
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
audioEmotionControlMethod: node.metadata?.audioEmotionControlMethod || globalConfig.audioEmotionControlMethod || defaultConfig.audioEmotionControlMethod,
audioEmotionRandom: node.metadata?.audioEmotionRandom || globalConfig.audioEmotionRandom || defaultConfig.audioEmotionRandom,

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, RefreshCw, ScanSearch, Settings2, Star, Trash2, Type, Video, WandSparkles } from "lucide-react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, RefreshCw, ScanSearch, Settings2, Star, Trash2, Type, UserRound, Video, WandSparkles } from "lucide-react";
import { useCanvasNodeActions } from "./canvas-node-action-context";
@@ -668,7 +668,7 @@ function NodeExternalHeader({ node, scale, dimensionLabel, active, editable, edi
// 标题保持屏幕尺寸只适用于近景;远景继续反向缩放会遮住节点和连线。
if (scale < NODE_EXTERNAL_HEADER_MIN_SCALE && !editing) return null;
const inverseScale = 1 / Math.max(scale, 0.05);
const Icon = nodeTypeIcon(node.type);
const Icon = nodeTypeIcon(node);
const maxHeaderWidth = Math.min(240, node.width * scale);
return (
@@ -719,7 +719,9 @@ function NodeExternalHeader({ node, scale, dimensionLabel, active, editable, edi
);
}
function nodeTypeIcon(type: CanvasNodeTypeId) {
function nodeTypeIcon(node: CanvasNodeData) {
if (node.metadata?.workflowKind === "character") return UserRound;
const type = node.type;
if (type === CanvasNodeType.Image) return ImageIcon;
if (type === CanvasNodeType.Video) return Video;
if (type === CanvasNodeType.Audio) return Music2;

View File

@@ -0,0 +1,177 @@
import { Input } from "antd";
import { Check, ImagePlus, Loader2, Pause, Play, Search, Upload } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { getResourceAccess, resolveResourceAccessURL, resourceIdFromStorageKey } from "@/services/api/resources";
import { loadAssetLibraryPage } from "@/services/user-data-sync";
import type { AudioAsset, ImageAsset } from "@/stores/use-asset-store";
const PAGE_SIZE = { image: 18, audio: 20 } as const;
/** 素材库分页查询:只保留已同步到云端、可被角色卡绑定的素材,滚到底部自动加载下一页。 */
function useCharacterMediaPages<K extends "image" | "audio">(kind: K, keyword: string) {
const query = useDebouncedValue(keyword.trim(), 250);
const pages = useInfiniteQuery({
queryKey: ["character-media-library", kind, query],
initialPageParam: 1,
queryFn: ({ signal, pageParam }) => loadAssetLibraryPage({ page: pageParam, pageSize: PAGE_SIZE[kind], kind, status: "active", query: query || undefined, signal }),
getNextPageParam: (page, all) => (all.reduce((count, item) => count + item.assets.length, 0) < page.total ? all.length + 1 : undefined),
});
const items = (pages.data?.pages.flatMap((page) => page.assets) || []).filter(
(asset): asset is K extends "image" ? ImageAsset : AudioAsset => asset.kind === kind && Boolean(resourceIdFromStorageKey((asset as ImageAsset | AudioAsset).data.storageKey)),
);
const sentinelRef = useRef<HTMLDivElement>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = pages;
useEffect(() => {
const target = sentinelRef.current;
if (!target || !hasNextPage || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting) && !isFetchingNextPage) void fetchNextPage();
}, { rootMargin: "160px" });
observer.observe(target);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return { items, query, loading: pages.isLoading, loadingMore: isFetchingNextPage, sentinelRef };
}
function SearchField({ value, placeholder, onChange }: { value: string; placeholder: string; onChange: (value: string) => void }) {
return <Input allowClear variant="filled" value={value} prefix={<Search className="size-3.5 text-foreground/40" />} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />;
}
function FilePicker({ accept, onFile, children }: { accept: string; onFile: (file: File) => void; children: (open: () => void) => ReactNode }) {
const inputRef = useRef<HTMLInputElement>(null);
return <>
{children(() => inputRef.current?.click())}
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ""; if (file) onFile(file); }} />
</>;
}
function ListState({ loading, empty, loadingMore }: { loading: boolean; empty: string; loadingMore: boolean }) {
if (loading) return <p className="py-10 text-center text-xs text-foreground/40">正在读取素材库</p>;
if (empty) return <p className="py-10 text-center text-xs leading-5 text-foreground/40">{empty}</p>;
return loadingMore ? <p className="py-4 text-center text-xs text-foreground/40">正在加载更多</p> : null;
}
/** 同一资源在当前画布上有节点时,用节点名(用户改过的名字)代替素材库里的默认标题。 */
export type CanvasMediaTitles = ReadonlyMap<string, string>;
/** 形象选择:缩略图网格,点选即绑定;第一格为本地上传。 */
export function CharacterImageLibrary({ currentResourceId, busyKey, canvasTitles, onPick, onUpload }: {
currentResourceId: string;
canvasTitles?: CanvasMediaTitles;
busyKey: string;
onPick: (resourceId: string) => void;
onUpload: (file: File) => void;
}) {
const [keyword, setKeyword] = useState("");
const { items, query, loading, loadingMore, sentinelRef } = useCharacterMediaPages("image", keyword);
return <div className="space-y-4">
<SearchField value={keyword} placeholder="搜索素材库图片" onChange={setKeyword} />
<div className="grid grid-cols-3 gap-2.5">
<FilePicker accept="image/*" onFile={onUpload}>
{(open) => <button type="button" onClick={open} disabled={Boolean(busyKey)} className="grid aspect-[3/4] place-items-center rounded-lg border border-dashed border-foreground/15 text-foreground/45 outline-none transition-colors hover:border-foreground/35 hover:text-foreground/75 focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] disabled:cursor-wait">
<span className="flex flex-col items-center gap-2 text-xs">{busyKey === "upload" ? <Loader2 className="size-5 animate-spin" /> : <ImagePlus className="size-5 stroke-[1.5]" />}上传本地图片</span>
</button>}
</FilePicker>
{items.map((asset) => {
const resourceId = resourceIdFromStorageKey(asset.data.storageKey);
const current = resourceId === currentResourceId;
const busy = busyKey === resourceId;
const title = canvasTitles?.get(resourceId) || asset.title;
return <button
key={asset.id}
type="button"
title={title}
aria-pressed={current}
disabled={Boolean(busyKey) || current}
onClick={() => onPick(resourceId)}
className={`group relative aspect-[3/4] overflow-hidden rounded-lg bg-foreground/[.05] outline-none transition focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] disabled:cursor-default ${current ? "ring-2 ring-[var(--workspace-accent)] ring-offset-2 ring-offset-background" : ""}`}
>
<CachedResourceImage storageKey={asset.data.storageKey} src={asset.coverUrl} variant="thumbnail" alt={title} className="h-full w-full object-cover transition-transform duration-300 [transition-timing-function:cubic-bezier(.16,1,.3,1)] group-hover:scale-[1.04] motion-reduce:transition-none" />
<span className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-1 bg-gradient-to-t from-black/75 to-transparent px-2 pb-1.5 pt-6 text-left text-[11px] text-white opacity-0 transition duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<span className="block truncate">{title}</span>
</span>
{current ? <span className="absolute right-1.5 top-1.5 grid size-5 place-items-center rounded-full bg-[var(--workspace-accent)] text-background"><Check className="size-3" /></span> : null}
{busy ? <span className="absolute inset-0 grid place-items-center bg-black/45 text-white"><Loader2 className="size-5 animate-spin" /></span> : null}
</button>;
})}
</div>
<ListState loading={loading} loadingMore={loadingMore} empty={!loading && !items.length ? (query ? "没有匹配的图片" : "素材库里还没有图片,可以直接上传本地图片") : ""} />
<div ref={sentinelRef} />
</div>;
}
/** 共用一个播放器试听,避免每行一个原生控件把列表挤乱。 */
function useAudition() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState("");
useEffect(() => () => audioRef.current?.pause(), []);
const toggle = async (resourceId: string, storageKey?: string) => {
const audio = audioRef.current || (audioRef.current = new Audio());
if (playing === resourceId) {
audio.pause();
setPlaying("");
return;
}
audio.pause();
setPlaying(resourceId);
try {
audio.src = resolveResourceAccessURL((await getResourceAccess(storageKey, "display", "playback")).url);
audio.onended = () => setPlaying("");
await audio.play();
} catch {
setPlaying("");
}
};
return { playing, toggle };
}
/** 声音选择:列表 + 单击试听,右侧“使用”即绑定。 */
export function CharacterVoiceLibrary({ currentResourceId, busyKey, canvasTitles, onPick, onUpload }: {
currentResourceId: string;
canvasTitles?: CanvasMediaTitles;
busyKey: string;
onPick: (resourceId: string, title: string) => void;
onUpload: (file: File) => void;
}) {
const [keyword, setKeyword] = useState("");
const { items, query, loading, loadingMore, sentinelRef } = useCharacterMediaPages("audio", keyword);
const { playing, toggle } = useAudition();
return <div className="space-y-4">
<div className="flex gap-2">
<div className="min-w-0 flex-1"><SearchField value={keyword} placeholder="搜索素材库音频" onChange={setKeyword} /></div>
<FilePicker accept="audio/*" onFile={onUpload}>
{(open) => <button type="button" onClick={open} disabled={Boolean(busyKey)} aria-label="上传本地音频" title="上传本地音频" className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.05] text-foreground/60 outline-none transition-colors hover:bg-foreground/[.09] hover:text-foreground focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] disabled:cursor-wait">
{busyKey === "upload" ? <Loader2 className="size-4 animate-spin" /> : <Upload className="size-4" />}
</button>}
</FilePicker>
</div>
<ul className="divide-y divide-border/60">
{items.map((asset) => {
const resourceId = resourceIdFromStorageKey(asset.data.storageKey);
const current = resourceId === currentResourceId;
const seconds = asset.data.durationMs ? Math.round(asset.data.durationMs / 1000) : 0;
const title = canvasTitles?.get(resourceId) || asset.title;
return <li key={asset.id} className="group flex items-center gap-3 py-2.5">
<button type="button" onClick={() => void toggle(resourceId, asset.data.storageKey)} aria-label={`${playing === resourceId ? "暂停" : "试听"} ${title}`} className={`grid size-8 shrink-0 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] ${playing === resourceId ? "bg-[var(--workspace-accent)] text-background" : "bg-foreground/[.06] text-foreground/70 hover:bg-foreground/[.1]"}`}>
{playing === resourceId ? <Pause className="size-3.5" /> : <Play className="size-3.5 translate-x-px" />}
</button>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{title}</span>
<span className="text-[11px] text-foreground/40">{seconds ? `${seconds} 秒` : "音频"}</span>
</span>
{current ? <span className="inline-flex items-center gap-1 text-xs font-medium text-foreground/70"><Check className="size-3.5" />使用中</span> : (
<button type="button" disabled={Boolean(busyKey)} onClick={() => onPick(resourceId, title)} className="rounded-md px-2.5 py-1 text-xs font-medium text-foreground/55 opacity-0 outline-none transition hover:bg-foreground/[.07] hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-[var(--workspace-accent)] group-hover:opacity-100 disabled:cursor-wait">
{busyKey === resourceId ? <Loader2 className="size-3.5 animate-spin" /> : "使用"}
</button>
)}
</li>;
})}
</ul>
<ListState loading={loading} loadingMore={loadingMore} empty={!loading && !items.length ? (query ? "没有匹配的音频" : "素材库里还没有音频,可以直接上传本地音频") : ""} />
<div ref={sentinelRef} />
</div>;
}