fix(web): 音频与角色素材 - 修正格式检查未通过的代码风格

角色库、音频参数和音频测试在上次提交后不再符合 Prettier,质量检查因此失败。只调整格式,不改变交互。
This commit is contained in:
ddcat666
2026-10-01 13:18:06 +08:00
parent 839db1c319
commit 080dbcbabe
4 changed files with 236 additions and 142 deletions

View File

@@ -47,46 +47,50 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
<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}>
{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>
{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>
) : (
<>
<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>
<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">
{formatOptions.map((item) => (
@@ -117,16 +121,18 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
onMouseDown={(event) => event.stopPropagation()}
/>
</SettingGroup>
{!isDoubao ? <SettingGroup title="声音指令" color={theme.node.muted}>
<textarea
value={config.audioInstructions || ""}
placeholder="例如:自然、温暖、适合旁白。"
className="thin-scrollbar h-20 w-full resize-none rounded-xl border bg-transparent px-3 py-2 text-sm leading-5 outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
onChange={(event) => onConfigChange("audioInstructions", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
/>
</SettingGroup> : null}
{!isDoubao ? (
<SettingGroup title="声音指令" color={theme.node.muted}>
<textarea
value={config.audioInstructions || ""}
placeholder="例如:自然、温暖、适合旁白。"
className="thin-scrollbar h-20 w-full resize-none rounded-xl border bg-transparent px-3 py-2 text-sm leading-5 outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
onChange={(event) => onConfigChange("audioInstructions", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
/>
</SettingGroup>
) : null}
</div>
</ImageSettingsTheme>
);

View File

@@ -107,18 +107,36 @@ export function CanvasCharacterLibraryModal({
<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="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 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); }} />
<PaginationBar
current={page}
pageSize={pageSize}
total={total}
pageSizeOptions={[12, 24, 48]}
itemLabel="张"
onChange={(nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
}}
/>
</div>
</div>
</AppModal>

View File

@@ -28,9 +28,12 @@ function useCharacterMediaPages<K extends "image" | "audio">(kind: K, keyword: s
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" });
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]);
@@ -43,10 +46,22 @@ function SearchField({ value, placeholder, onChange }: { value: string; placehol
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); }} />
</>;
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 }) {
@@ -59,49 +74,68 @@ function ListState({ loading, empty, loadingMore }: { loading: boolean; empty: s
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;
}) {
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>;
})}
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>
<ListState loading={loading} loadingMore={loadingMore} empty={!loading && !items.length ? (query ? "没有匹配的图片" : "素材库里还没有图片,可以直接上传本地图片") : ""} />
<div ref={sentinelRef} />
</div>;
);
}
/** 共用一个播放器试听,避免每行一个原生控件把列表挤乱。 */
@@ -130,7 +164,13 @@ function useAudition() {
}
/** 声音选择:列表 + 单击试听,右侧“使用”即绑定。 */
export function CharacterVoiceLibrary({ currentResourceId, busyKey, canvasTitles, onPick, onUpload }: {
export function CharacterVoiceLibrary({
currentResourceId,
busyKey,
canvasTitles,
onPick,
onUpload,
}: {
currentResourceId: string;
canvasTitles?: CanvasMediaTitles;
busyKey: string;
@@ -140,38 +180,68 @@ export function CharacterVoiceLibrary({ currentResourceId, busyKey, canvasTitles
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" /> : "使用"}
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>
)}
</li>;
})}
</ul>
<ListState loading={loading} loadingMore={loadingMore} empty={!loading && !items.length ? (query ? "没有匹配的音频" : "素材库里还没有音频,可以直接上传本地音频") : ""} />
<div ref={sentinelRef} />
</div>;
</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>
);
}

View File

@@ -1,14 +1,15 @@
import { describe, expect, test } from "bun:test";
import { doubaoAudioVoiceOptions, isDoubaoAudioConfig, normalizeAudioVoiceForConfig, normalizeAudioVoiceValue } from "../src/lib/audio-generation";
const config = (protocol: string) => ({
provider: "custom",
model: "models/index-tts",
audioModel: "models/index-tts",
apiKey: "",
baseUrl: "",
channels: [{ models: ["index-tts"], modelCosts: [], interfaceType: protocol, baseUrl: "", apiKey: "" }],
} as any);
const config = (protocol: string) =>
({
provider: "custom",
model: "models/index-tts",
audioModel: "models/index-tts",
apiKey: "",
baseUrl: "",
channels: [{ models: ["index-tts"], modelCosts: [], interfaceType: protocol, baseUrl: "", apiKey: "" }],
}) as any;
describe("audio voice protocol handling", () => {
test("keeps custom voice identifiers for non-OpenAI channels", () => {
@@ -37,5 +38,4 @@ describe("audio voice protocol handling", () => {
const doubao = { ...config("doubao-streaming-tts"), model: "seed-audio-1.0", audioModel: "seed-audio-1.0" } as any;
expect(normalizeAudioVoiceForConfig(doubao, "zh_male_custom_official_speaker")).toBe("zh_male_custom_official_speaker");
});
});