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

@@ -3,10 +3,11 @@ import type { MenuProps } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, RotateCcw, Search, Trash2, Upload, UserRound, Video } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useUserStore } from "@/stores/use-user-store";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AudioPlayButton, AudioWaveArt, CharacterAssetCover } from "@/components/assets/asset-rich-cover";
import { AssetLibraryCard } from "@/components/assets/asset-library-card";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { PaginationBar } from "@/components/layout/workspace-page";
@@ -49,6 +50,10 @@ export type AssetLibraryPickerFolder = {
type Props = {
remoteLibrary?: boolean;
remoteKind?: string;
/** 远端素材库里的角色卡是否展示并可选(插入画布时会作为角色卡节点)。 */
allowCharacters?: boolean;
/** 调用方能处理 onConfirm 的 pickedItems 时设为 true:远端分页里不在本地缓存的素材也可选。 */
acceptRemoteItems?: boolean;
/** 左侧「媒体类型」筛选项;只有一种类型或已由 remoteKind 固定时不展示该分组。 */
mediaKinds?: AssetPickerMediaKind[];
open: boolean;
@@ -80,13 +85,16 @@ type Props = {
};
};
onClose: () => void;
onConfirm: (ids: string[]) => Promise<void> | void;
/** pickedItems 包含远端分页里选中的条目(它们不在调用方传入的 items 里)。 */
onConfirm: (ids: string[], pickedItems?: AssetLibraryPickerItem[]) => Promise<void> | void;
onFolderAction?: (folderId: string) => Promise<void> | void;
};
export function AssetLibraryPickerModal({
remoteLibrary = false,
remoteKind,
allowCharacters = false,
acceptRemoteItems = false,
mediaKinds = DEFAULT_MEDIA_KINDS,
open,
items,
@@ -138,15 +146,43 @@ export function AssetLibraryPickerModal({
const remoteQueryKind = remoteKind || (mediaKind === "all" ? undefined : mediaKind);
const remoteQuery = useQuery({
queryKey: ["asset-picker", userId, remotePage, remotePageSize, category, remoteKeyword, remoteQueryKind],
queryFn: ({ signal }) => loadAssetLibraryPage({ page: remotePage, pageSize: remotePageSize, kind: remoteQueryKind, category: category === "all" || category === "archived" || category === remoteQueryKind ? undefined : category, status: category === "archived" ? "archived" : "active", query: remoteKeyword, signal }),
queryFn: ({ signal }) =>
loadAssetLibraryPage({
page: remotePage,
pageSize: remotePageSize,
kind: remoteQueryKind,
category: category === "all" || category === "archived" || category === remoteQueryKind ? undefined : category,
status: category === "archived" ? "archived" : "active",
query: remoteKeyword,
signal,
}),
enabled: remoteEnabled && open && sessionHydrated,
// 切分类/翻页时保留上一页,避免加载间隙退回本地数据源、左侧分类被重置(点"角色"闪一下又跳回全部)。
placeholderData: keepPreviousData,
});
const remoteItems = useMemo<AssetLibraryPickerItem[]>(() => (remoteQuery.data?.assets || []).filter((asset) => asset.kind !== "entity" && asset.kind !== "model").map((asset) => ({
id: asset.id, title: asset.title, category: asset.category || "other", archived: asset.status === "archived", asset,
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本", searchText: (asset.tags ?? []).join(" "),
...(items.find((item) => item.id === asset.id) || { disabledReason: "此素材不适用于当前操作" }),
})), [remoteQuery.data, items]);
// 调用方的 items 只是本地缓存,不代表可插入集合(分页结果大多不在缓存里)。能接收 pickedItems 的
// 调用方(acceptRemoteItems)允许直接选远端条目;其余调用方保持"不在 items 里就不可选"的旧约束。
const remoteItems = useMemo<AssetLibraryPickerItem[]>(
() =>
(remoteQuery.data?.assets || [])
.filter((asset) => asset.kind !== "model" && (asset.kind !== "entity" || allowCharacters))
.map((asset) => {
const known = items.find((item) => item.id === asset.id);
return {
id: asset.id,
title: asset.title,
category: asset.category || "other",
archived: asset.status === "archived",
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : asset.kind === "entity" ? "角色" : "文本",
searchText: (asset.tags ?? []).join(" "),
...(known || (acceptRemoteItems ? {} : { disabledReason: "此素材不适用于当前操作" })),
asset,
};
}),
[acceptRemoteItems, allowCharacters, remoteQuery.data, items],
);
const uploadInputRef = useRef<HTMLInputElement>(null);
const pickedRemoteRef = useRef(new Map<string, AssetLibraryPickerItem>());
const initialSelectedIdsRef = useRef(initialSelectedIds);
const itemsRef = useRef(items);
initialSelectedIdsRef.current = initialSelectedIds;
@@ -163,7 +199,17 @@ export function AssetLibraryPickerModal({
const preferLocalUnsynced = remoteReady && remoteTotal === 0 && localItems.length > 0;
const remoteEntityOnlyPage = remoteReady && remoteItems.length === 0 && remoteTotal > 0;
const useRemoteItems = remoteReady && !preferLocalUnsynced && !remoteEntityOnlyPage && (remoteItems.length > 0 || remoteTotal === 0);
const effectivePagination = useRemoteItems ? { current: remotePage, pageSize: remotePageSize, total: remoteTotal, onChange: (page: number, pageSize: number) => { setRemotePage(page); setRemotePageSize(pageSize); } } : pagination;
const effectivePagination = useRemoteItems
? {
current: remotePage,
pageSize: remotePageSize,
total: remoteTotal,
onChange: (page: number, pageSize: number) => {
setRemotePage(page);
setRemotePageSize(pageSize);
},
}
: pagination;
const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]);
const hasPluginSource = useMemo(() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), [categoryLabels, pluginItems]);
// 媒体类型在分类之前收窄数据源,让左侧分类计数、网格和分页始终描述同一批素材。
@@ -177,7 +223,13 @@ export function AssetLibraryPickerModal({
const mediaKindOptions = useMemo(() => (remoteKind ? [] : Array.from(new Set(mediaKinds))), [mediaKinds, remoteKind]);
const sourceFolders = source === "plugin" ? folders : [];
const showCategories = source === "local" || !sourceFolders.length;
const normalCategories = useMemo(() => useRemoteItems ? Object.keys(categoryLabels).filter((value) => value !== "archived" && !value.startsWith("external:")) : ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems, categoryLabels, useRemoteItems]);
const normalCategories = useMemo(
() =>
useRemoteItems
? Object.keys(categoryLabels).filter((value) => value !== "archived" && !value.startsWith("external:"))
: ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")],
[activeSourceItems, categoryLabels, useRemoteItems],
);
const archivedCount = archivedItems.length;
const isRecycleBin = category === "archived";
@@ -212,6 +264,7 @@ export function AssetLibraryPickerModal({
setSource("local");
setKeyword("");
setUploadedItems([]);
pickedRemoteRef.current.clear();
const selectableIds = new Set(itemsRef.current.filter((item) => !item.disabledReason).map((item) => item.id));
setSelected(new Set(Array.from(initialSelectedIdsRef.current || []).filter((id) => selectableIds.has(id))));
setWorking(false);
@@ -221,8 +274,10 @@ export function AssetLibraryPickerModal({
useEffect(() => {
if (category === "all" || category === "archived" || normalCategories.includes(category)) return;
// 远端分页还在加载时分类列表是临时的,不能据此把用户刚点的分类改回"全部"。
if (remoteEnabled && (remoteQuery.isFetching || !remoteQuery.isSuccess)) return;
setCategory("all");
}, [normalCategories, category]);
}, [normalCategories, category, remoteEnabled, remoteQuery.isFetching, remoteQuery.isSuccess]);
useEffect(() => {
if (hasPluginSource || source === "local") return;
@@ -244,6 +299,8 @@ export function AssetLibraryPickerModal({
const toggle = (item: AssetLibraryPickerItem) => {
if (item.disabledReason || working) return;
// 远端分页条目翻页后就不在列表里了;选中时记下来,确认时仍能转成插入内容。
if (item.asset && !allItems.some((known) => known.id === item.id)) pickedRemoteRef.current.set(item.id, item);
setError("");
setSelected((current) => {
if (!multiple) return current.has(item.id) ? new Set() : new Set([item.id]);
@@ -259,7 +316,8 @@ export function AssetLibraryPickerModal({
setWorking(true);
setError("");
try {
await onConfirm(selectedIds);
const known = new Set(allItems.map((item) => item.id));
await onConfirm(selectedIds, [...allItems, ...[...pickedRemoteRef.current.values()].filter((item) => !known.has(item.id))]);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "素材操作失败,请重试");
} finally {
@@ -354,7 +412,12 @@ export function AssetLibraryPickerModal({
}
};
const countFor = (value: string) => (value === "all" ? activeSourceItems.length : activeSourceItems.filter((item) => item.category === value).length);
// 远端模式下列表是分页的,按当前页数会把"角色 4"之类的数字算错;未叠加类型筛选时用服务端分类计数。
const remoteCategoryCounts = useRemoteItems && mediaKind === "all" && !remoteKind ? remoteQuery.data?.categoryCounts : undefined;
const countFor = (value: string) => {
if (remoteCategoryCounts) return value === "all" ? Object.values(remoteCategoryCounts).reduce((sum, count) => sum + count, 0) : remoteCategoryCounts[value] || 0;
return value === "all" ? activeSourceItems.length : activeSourceItems.filter((item) => item.category === value).length;
};
const sourceLabel = source === "plugin" ? "插件来源" : "本地素材";
const sourceMenuItems: MenuProps["items"] = [
{
@@ -486,7 +549,14 @@ export function AssetLibraryPickerModal({
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{remoteEnabled && remoteQuery.isError && !localItems.length ? <div className="asset-picker-empty" role="alert"><FolderOpen /><strong>素材读取失败</strong><span>服务端暂时不可用,重试不会影响本地已保存素材。</span><Button onClick={() => void remoteQuery.refetch()}>重试</Button></div> : loading || (useRemoteItems && remoteQuery.isFetching) ? (
{remoteEnabled && remoteQuery.isError && !localItems.length ? (
<div className="asset-picker-empty" role="alert">
<FolderOpen />
<strong>素材读取失败</strong>
<span>服务端暂时不可用,重试不会影响本地已保存素材。</span>
<Button onClick={() => void remoteQuery.refetch()}>重试</Button>
</div>
) : loading || (useRemoteItems && remoteQuery.isFetching) ? (
<div className="asset-picker-empty">
<LoaderCircle className="animate-spin" />
<strong>正在读取素材</strong>
@@ -502,7 +572,9 @@ export function AssetLibraryPickerModal({
</div>
)}
</div>
{effectivePagination ? <PaginationBar alwaysShow current={effectivePagination.current} pageSize={effectivePagination.pageSize} total={effectivePagination.total} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={effectivePagination.onChange} /> : null}
{effectivePagination ? (
<PaginationBar alwaysShow current={effectivePagination.current} pageSize={effectivePagination.pageSize} total={effectivePagination.total} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={effectivePagination.onChange} />
) : null}
</div>
</div>
<footer className={cn("asset-picker-footer", !activeUpload && "is-compact")}>
@@ -530,7 +602,14 @@ export function AssetLibraryPickerModal({
<div className="asset-picker-actions">
{isRecycleBin ? (
<>
<Popconfirm title={remoteEnabled ? "确认删除当前页回收站素材?" : "确认清空回收站?"} description="仅删除当前列表中的素材;关联文件会直接释放,原画布或任务中的旧引用可能失效。删除不可恢复。" onConfirm={handleEmptyRecycleBin} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
<Popconfirm
title={remoteEnabled ? "确认删除当前页回收站素材?" : "确认清空回收站?"}
description="仅删除当前列表中的素材;关联文件会直接释放,原画布或任务中的旧引用可能失效。删除不可恢复。"
onConfirm={handleEmptyRecycleBin}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
<Button type="text" danger disabled={working || !archivedCount}>
{remoteEnabled ? "删除当前页" : "清空回收站"}
</Button>
@@ -579,10 +658,14 @@ export function pickerItemMediaKind(item: AssetLibraryPickerItem): AssetPickerMe
function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem; selected: boolean; onToggle: () => void }) {
const disabled = Boolean(item.disabledReason);
return (
<AssetLibraryCard selected={selected} className={cn("asset-picker-card", disabled && "is-disabled")}>
<AssetLibraryCard selected={selected} className={cn("asset-picker-card relative", disabled && "is-disabled")}>
<button type="button" className="asset-picker-card-action" onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
<div className="assets-cover asset-picker-card-media">
{item.imageUrl || item.imageStorageKey ? (
{item.asset?.kind === "entity" ? (
<CharacterAssetCover asset={item.asset} />
) : item.asset?.kind === "audio" && !item.imageUrl && !item.imageStorageKey ? (
<AudioWaveArt seed={item.asset.id} />
) : item.imageUrl || item.imageStorageKey ? (
<CachedResourceImage
storageKey={item.imageStorageKey}
src={item.imageUrl}
@@ -609,6 +692,12 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
{item.description ? <span>{item.description}</span> : null}
</div>
</button>
{item.asset?.kind === "audio" && !disabled ? (
// 与封面同尺寸的覆盖层:试听按钮落在封面左下角,又不嵌套在选择按钮里。
<span className="pointer-events-none absolute inset-x-0 top-0 aspect-[4/3]">
<AudioPlayButton asset={item.asset} className="pointer-events-auto absolute bottom-2 left-2 !size-8" />
</span>
) : null}
</AssetLibraryCard>
);
}

View File

@@ -0,0 +1,137 @@
import { Mic, Pause, Play } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { getResourceAccess, resolveResourceAccessURL } from "@/services/api/resources";
import type { AudioAsset, EntityAsset } from "@/stores/use-asset-store";
function initial(name: string) {
return Array.from(name.trim())[0] || "角";
}
/** 换取资源展示地址;一次换取供多个 <img> 共用,避免两个图片组件各自加载、各自失败。 */
function useResourceDisplayUrl(storageKey: string | undefined, variant: "original" | "thumbnail") {
const [resolved, setResolved] = useState<{ key: string; url: string; failed: boolean } | null>(null);
const key = storageKey ? `${storageKey}:${variant}` : "";
useEffect(() => {
if (!storageKey) return;
let cancelled = false;
getResourceAccess(storageKey, "display", variant)
.catch(() => (variant === "thumbnail" ? getResourceAccess(storageKey, "display", "original") : Promise.reject(new Error("resource unavailable"))))
.then((access) => {
if (!cancelled) setResolved({ key, url: resolveResourceAccessURL(access.url), failed: false });
})
.catch(() => {
if (!cancelled) setResolved({ key, url: "", failed: true });
});
return () => {
cancelled = true;
};
}, [key, storageKey, variant]);
return resolved?.key === key ? resolved : null;
}
/**
* 角色卡封面:立绘 + 同图模糊底,名字首字作空态。素材库卡片、素材档案与画布素材选择共用,
* 保证角色卡在任何入口都长得像一张角色卡,而不是一个灰底图标。
*/
export function CharacterAssetCover({ asset, size = "card" }: { asset: EntityAsset; size?: "card" | "hero" }) {
const storageKey = asset.data.coverStorageKey;
const cover = useResourceDisplayUrl(storageKey, size === "hero" ? "original" : "thumbnail");
const [broken, setBroken] = useState("");
const url = cover?.url && broken !== cover.url ? cover.url : "";
const showEmpty = !storageKey || cover?.failed || (cover && !url);
return (
<span className="relative isolate block h-full w-full overflow-hidden bg-[#111114]">
{url ? (
<>
<img src={url} alt="" aria-hidden className="absolute inset-0 h-full w-full scale-125 object-cover opacity-45 blur-2xl saturate-125" draggable={false} />
<img src={url} alt={asset.title} decoding="async" className="absolute inset-0 h-full w-full object-contain p-2" draggable={false} onError={() => setBroken(url)} />
</>
) : showEmpty ? (
<span className="absolute inset-0 grid place-items-center bg-[radial-gradient(circle_at_50%_40%,rgba(255,255,255,.1),transparent_62%)]">
<span className={`grid place-items-center rounded-full border border-white/15 font-light text-white/75 ${size === "hero" ? "size-24 text-4xl" : "size-14 text-2xl"}`}>{initial(asset.title)}</span>
</span>
) : (
<span className="absolute inset-0 animate-pulse bg-white/[.04] motion-reduce:animate-none" aria-hidden />
)}
<span className="pointer-events-none absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/55 to-transparent" />
{asset.data.voiceName ? (
<span className="pointer-events-none absolute bottom-2 right-2 inline-flex max-w-[70%] items-center gap-1 rounded-full bg-black/55 px-2 py-0.5 text-[10px] text-white/85 backdrop-blur">
<Mic className="size-3 shrink-0" />
<span className="truncate">{asset.data.voiceName}</span>
</span>
) : null}
</span>
);
}
export function characterAssetSummary(asset: EntityAsset) {
const role = typeof asset.data.definition.role === "string" ? asset.data.definition.role.trim() : "";
const appearance = typeof asset.data.definition.appearance === "string" ? asset.data.definition.appearance.trim() : "";
const states = [asset.data.visualStatus === "ready" ? "形象就绪" : "形象待完善", asset.data.voiceName ? `声音:${asset.data.voiceName}` : "未绑定声音"];
return [role || appearance, ...states].filter(Boolean).join(" · ");
}
/** 音频封面图形:确定性波形,不含交互(可放进按钮里)。 */
export function AudioWaveArt({ seed, active = false }: { seed: string; active?: boolean }) {
return (
<span className="relative flex h-full w-full items-center justify-center gap-[3px] bg-[linear-gradient(135deg,color-mix(in_srgb,var(--foreground)_8%,transparent),color-mix(in_srgb,var(--foreground)_2%,transparent))] px-[14%]" aria-hidden>
{waveBars(seed).map((height, index) => (
<span key={index} className={`w-[3px] shrink rounded-full transition-colors duration-200 ${active ? "bg-[var(--workspace-accent)]" : "bg-foreground/30"}`} style={{ height: `${height}%` }} />
))}
</span>
);
}
/** 音频试听按钮:放在卡片按钮之外(绝对定位),避免嵌套可交互元素。 */
export function AudioPlayButton({ asset, className = "", onPlayingChange }: { asset: AudioAsset; className?: string; onPlayingChange?: (playing: boolean) => void }) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlayingState] = useState(false);
const setPlaying = (value: boolean) => {
setPlayingState(value);
onPlayingChange?.(value);
};
useEffect(() => () => audioRef.current?.pause(), []);
const toggle = async () => {
const audio = audioRef.current || (audioRef.current = new Audio());
if (playing) {
audio.pause();
setPlaying(false);
return;
}
setPlaying(true);
try {
audio.src = asset.data.storageKey?.startsWith("resource:") ? resolveResourceAccessURL((await getResourceAccess(asset.data.storageKey, "display", "playback")).url) : asset.data.url;
audio.onended = () => setPlaying(false);
await audio.play();
} catch {
setPlaying(false);
}
};
return (
<button
type="button"
aria-label={`${playing ? "暂停" : "试听"} ${asset.title}`}
title={playing ? "暂停" : "试听"}
onClick={(event) => {
event.stopPropagation();
void toggle();
}}
className={`z-[3] grid size-9 place-items-center rounded-full bg-black/60 text-white outline-none backdrop-blur transition hover:scale-105 hover:bg-black/75 focus-visible:ring-2 focus-visible:ring-white/70 motion-reduce:transition-none ${className}`}
>
{playing ? <Pause className="size-4" /> : <Play className="size-4 translate-x-px" />}
</button>
);
}
function waveBars(seed: string) {
let hash = 0;
for (const char of seed) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
const bars: number[] = [];
for (let index = 0; index < 28; index += 1) {
hash = (hash * 9301 + 49297) % 233280;
const envelope = 0.35 + 0.65 * Math.abs(Math.sin(index * 0.55 + 1.2));
bars.push(Math.round((0.16 + 0.8 * (hash / 233280) * envelope) * 100));
}
return bars;
}

View File

@@ -1,7 +1,7 @@
import { type ReactNode } from "react";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { audioFormatOptions, audioSpeedLabel, audioVoiceOptions, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { audioFormatOptionsForConfig, audioSpeedLabel, audioVoiceOptionsForConfig, isDoubaoAudioConfig, normalizeAudioFormatForConfig, normalizeAudioSpeedValue, normalizeAudioVoiceForConfig } from "@/lib/audio-generation";
import { type CanvasTheme } from "@/lib/canvas-theme";
import type { AiConfig } from "@/stores/use-config-store";
@@ -11,6 +11,8 @@ type AudioSettingKey =
| "audioVoice"
| "audioFormat"
| "audioSpeed"
| "audioLanguage"
| "audioDialect"
| "audioInstructions"
| "audioEmotionControlMethod"
| "audioEmotionRandom"
@@ -32,26 +34,62 @@ type AudioSettingsPanelProps = {
};
export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[var(--panel-width-compact)] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) {
const voice = normalizeAudioVoiceValue(config.audioVoice);
const format = normalizeAudioFormatValue(config.audioFormat);
const isDoubao = isDoubaoAudioConfig(config);
const voice = normalizeAudioVoiceForConfig(config, config.audioVoice);
const voiceOptions = audioVoiceOptionsForConfig(config);
const visibleVoiceOptions = voiceOptions.some((item) => item.value === voice) ? voiceOptions : [{ value: voice, label: `当前音色(${voice})` }, ...voiceOptions];
const format = normalizeAudioFormatForConfig(config, config.audioFormat);
const formatOptions = audioFormatOptionsForConfig(config);
const speed = normalizeAudioSpeedValue(config.audioSpeed);
return (
<ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
{showTitle ? <div className="text-lg font-semibold">音频设置</div> : null}
<SettingGroup title="声音" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5">
{audioVoiceOptions.map((item) => (
<OptionPill key={item.value} selected={voice === item.value} theme={theme} onClick={() => onConfigChange("audioVoice", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</SettingGroup>
<SettingGroup title="音色" color={theme.node.muted}>
{isDoubao ? (
<div className="space-y-3">
<div className="rounded-xl border px-3 py-2 text-sm leading-6" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
不连接素材时,按提示词直接生成。连接音频时按参考音频生成,最多 3 段。连接图片时按参考图片生成,最多 1 张。图片和音频不能同时连接。
</div>
<select
value={voice}
className="h-9 w-full rounded-xl border bg-transparent px-3 text-sm outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text, background: theme.spatial.elevated }}
onChange={(event) => onConfigChange("audioVoice", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
>
<option value="">不指定音色</option>
{voice ? <option value={voice}>当前音色</option> : null}
</select>
</div>
) : (
<>
<input
value={config.audioVoice || ""}
placeholder="输入渠道音色 ID,例如 voice_001"
list="audio-voice-options"
className="h-9 w-full rounded-xl border bg-transparent px-3 text-sm outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
onChange={(event) => onConfigChange("audioVoice", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
/>
<datalist id="audio-voice-options">
{visibleVoiceOptions.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</datalist>
<div className="grid grid-cols-3 gap-2.5">
{visibleVoiceOptions.map((item) => (
<OptionPill key={item.value} selected={voice === item.value} theme={theme} onClick={() => onConfigChange("audioVoice", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</>
)}
</SettingGroup>
<SettingGroup title="格式" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5">
{audioFormatOptions.map((item) => (
{formatOptions.map((item) => (
<OptionPill key={item.value} selected={format === item.value} theme={theme} onClick={() => onConfigChange("audioFormat", item.value)}>
{item.label}
</OptionPill>
@@ -79,7 +117,7 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
onMouseDown={(event) => event.stopPropagation()}
/>
</SettingGroup>
<SettingGroup title="声音指令" color={theme.node.muted}>
{!isDoubao ? <SettingGroup title="声音指令" color={theme.node.muted}>
<textarea
value={config.audioInstructions || ""}
placeholder="例如:自然、温暖、适合旁白。"
@@ -88,64 +126,12 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
onChange={(event) => onConfigChange("audioInstructions", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
/>
</SettingGroup>
<SettingGroup title="情感控制(IndexTTS2)" color={theme.node.muted}>
<select
className="h-9 w-full rounded-xl border bg-transparent px-3 text-sm outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text, background: theme.spatial.elevated }}
value={config.audioEmotionControlMethod || "与音色参考音频相同"}
onChange={(event) => onConfigChange("audioEmotionControlMethod", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
>
<option value="与音色参考音频相同">与音色参考音频相同</option>
<option value="使用情感参考音频">使用情感参考音频</option>
</select>
<label className="flex items-center justify-between gap-3 text-sm">
<span>随机情感</span>
<input type="checkbox" checked={config.audioEmotionRandom === "true"} onChange={(event) => onConfigChange("audioEmotionRandom", String(event.target.checked))} />
</label>
<div className="grid grid-cols-2 gap-2">
{emotionFields.map(([key, label]) => (
<label key={key} className="flex items-center gap-2 text-xs">
<span className="min-w-0 flex-1">{label}</span>
<input
type="number"
min={0}
max={1}
step={0.01}
className="h-8 w-20 rounded-lg border bg-transparent px-2 text-right outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
value={config[key] || "0"}
onChange={(event) => onConfigChange(key, event.target.value)}
onBlur={(event) => onConfigChange(key, normalizeEmotionWeight(event.target.value))}
onMouseDown={(event) => event.stopPropagation()}
/>
</label>
))}
</div>
</SettingGroup>
</SettingGroup> : null}
</div>
</ImageSettingsTheme>
);
}
const emotionFields: Array<[Extract<AudioSettingKey, `audioEmotion${string}`>, string]> = [
["audioEmotionHappy", "快乐"],
["audioEmotionAngry", "愤怒"],
["audioEmotionSad", "悲伤"],
["audioEmotionAfraid", "害怕"],
["audioEmotionDisgusted", "厌恶"],
["audioEmotionMelancholic", "忧郁"],
["audioEmotionSurprised", "惊讶"],
["audioEmotionCalm", "平静"],
];
function normalizeEmotionWeight(value: string) {
const number = Number(value);
if (!Number.isFinite(number)) return "0";
return String(Math.max(0, Math.min(1, number)));
}
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button

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>;
}