实现 SFX V2 T4 生成面板交互

新增独立 SFX Prompt 助手状态机与请求隔离、单层撤销。

抽取通用音频预设跑马灯并接入 SFX 预设、计数和优化入口。

实现自动/手动时长、Loop、模型展示及同步提交参数冻结。

恢复项目布局状态,补齐回归测试并更新 T4 权威文档与共享决策。
This commit is contained in:
2026-08-07 03:49:04 +00:00
parent 9d1f4c1975
commit e1e4f487f5
27 changed files with 2895 additions and 562 deletions
@@ -2,7 +2,7 @@
日期:`2026-08-06`
状态:`T1、T2、T3 已完成,可以继续进入 T4T2 翻译 serviceT3 ElevenLabs adapter 均待 T5 正式接线,当前仍不是可发布切点`
状态:`T1、T2、T3、T4 已完成,可以继续进入 T5T2 翻译 serviceT3 ElevenLabs adapter 与 T4 前端冻结参数均待 T5 正式接线,当前仍不是可发布切点`
开发分支:`feat/sound_opt`
@@ -163,6 +163,12 @@ External v1 `model` 先删除首尾 Unicode `White_Space`,再按大小写敏
- 在第一个 await 前取得 AI / 提交 operation,只锁当前 SFX dialog;迟到响应和 scope 切换不写新面板。
- T4 不切换 provider,不是可发布切点;与 T5 同一发布列车。
实施记录(`2026-08-07`):T4 已完成。前端增加独立于 BGM 的 dialog-scoped SFX Prompt 状态模型与 controller,优化和提交都在第一个 `await` 前同步取得 operation;账号、项目、dialog、mode 和 `AbortController` 共同隔离迟到响应。优化成功形成一层 canonical Prompt 交换快照,失败清除本次临时快照且不恢复更早快照,预设写入清快照。现有 BGM 跑马灯已抽出无业务语义的音频内核,BGM / SFX 各保留 wrapperSFX wrapper 展示 T1 冻结的 `40 + 12` 预设。
共享音频 composer 的 SFX 分支现已展示 `0 / 2048` 计数、一键优化、单层撤销、自动 / 手动时长、`0.5-30s``0.1s` 步进的 slider、Loop、固定 `ElevenLabs` 胶囊和新模型前端 `5` 泥点兜底。dialog layout 保存并恢复 `soundDurationMode / soundDurationSeconds / soundLoop`;历史 Vidu dialog 和改造入口统一打开 SFX V2 模型面板。同步提交 claim 冻结 canonical Prompt、时长模式、最近手动值和 Loop,只锁当前 dialog,并在 scope 失效后拒绝旧 UI 写回。
T4 没有修改 Worker、provider 调用、正式请求的 nullable duration / Loop 映射、服务端动态定价、计费、OSS、持久化详情、External v1、OpenAPI 或 SpacetimeDB schema;这些继续严格归属 T5。T4 单独合入仍不是可发布切点,也未执行真实 LLM、ElevenLabs 或付费生成。
### T5:正式提交、Worker、计费、持久化、详情和 External v1
- 前端提交冻结 canonical Prompt、duration 和 Loop,正式 POST 不 unsafe retry。
@@ -6751,3 +6751,11 @@
- 翻译 service:在现有 `vector_engine_audio_generation` 内新增仅 crate 内部生成流水线可见、没有同步 HTTP 路由的翻译 service。每个业务语义轮固定 Luna、OpenAI Chat、Low、`max_tokens=8192`,不发送 temperature 或 tools;候选严格执行五字段结构、`true / true / true / false``1-2048` code points、至少一个 Latin alphabetic code point,且所有 alphabetic code point 都属于 Latin Script。Han、假名、韩文、西里尔、希腊和阿拉伯字母均失败,Common / Inherited 数字、标点、空白和符号允许。
- 轮次与错误:首轮成功返回但结构、判断、Script、长度或 `finish_reason=length` 不合格时,只以同一 canonical `userPrompt` 开启唯一第二业务语义轮;不得读取或传递首轮候选。首轮 `content_filter` 和 transport / timeout / 上游最终失败直接结束;`LlmClient` 内部 transport retry 仍属于当前业务轮。第二轮任何失败均最终失败。service 错误展示只提供 `translation_invalid / translation_upstream_failed` 分类和安全中文消息,不保存或输出未通过候选。
- 阶段边界:T2 没有调用 ElevenLabs、创建额外任务、扣费、修改队列 payload、持久化 `actual_prompt` 或变更 External v1 / OpenAPI / SpacetimeDB schema。T5 接入正式 Worker 时必须移除 T2 的 staged dead-code 豁免,并把翻译结果作为唯一 `actualPrompt` 进入 provider;T2 单独合入仍不是可发布切点。
## 2026-08-07 SFX 生成优化 V2.0 T4 前端 controller 与参数 UI
- 状态模型:SFX 使用独立于 BGM 的纯状态模型和 dialog-scoped controller。优化与提交均在第一个 `await` 前同步 claim operation;账号、项目、dialog、mode 与 `AbortController` 共同判定响应归属。关闭、删除、mode / scope 切换后的旧响应不能写回新面板;同一按钮双击只有第一个 operation 生效。
- Prompt 与撤销:计数、优化、预设、撤销和提交统一复用 T1 的 Unicode `White_Space` canonicalizer 与 `1-2048` code point 规则。优化开始时以当前 canonical Prompt 替换旧快照,成功转为单层交换快照,失败清除本次临时快照且不恢复更早快照;预设写入清快照,手动编辑优化结果后仍可在两个 canonical 版本间反复交换。
- 预设视图:BGM 预设跑马灯抽出无业务语义的音频内核,保留单一可访问控件队列、无缝滚动、hover、触摸、页面可见性和 reduced-motion 行为;BGM / SFX 各自保留 wrapper、预设模型和业务 class。SFX wrapper 展示固定 `40 + 12` 预设,不保存展开、滚动或 hover 状态。
- 参数与布局:SFX 首次打开为手动 `5s`、Loop false;手动 slider 为 `0.5-30s`、步进 `0.1s`,自动模式禁用 slider 但保留最近手动值。layout 恢复 `soundDurationMode / soundDurationSeconds / soundLoop`,历史 Vidu dialog 与改造入口统一打开固定 `eleven_text_to_sound_v2` / `ElevenLabs` 面板。前端显示新模型 `5` 泥点兜底,正式价格仍以后端 T5 入队冻结值为真相。
- 锁与阶段边界:优化、提交或既有生成态只锁当前 SFX dialog 的输入、预设、滚动、参数、撤销和生成。提交 claim 同步冻结 canonical Prompt、时长模式、最近手动值与 Loop;T4 不改变正式请求的 nullable duration / Loop 映射,不接 Worker 翻译或 ElevenLabs,不修改服务端动态定价、计费、OSS、持久化详情、External v1、OpenAPI 或 SpacetimeDB schema。T4 必须与 T5 同一发布列车,不能单独发布。
@@ -2,7 +2,7 @@
日期:`2026-06-18`
更新时间:`2026-08-06`
更新时间:`2026-08-07`
## 范围
@@ -12,7 +12,7 @@
音效与背景音乐继续使用同一个音频 composer,并由组件内的 `isSoundEffect = dialog.mode === 'audio-sound-effect'` 隔离行为。共享视图不等于共享业务规则:BGM 继续使用 Suno、200 字 canonical Prompt、30 个预设、AI 补全 / 简化、单层撤销和方案 A 提交锁;SFX V2 固定使用 ElevenLabs `eleven_text_to_sound_v2`、52 个预设、一键优化、自动中译英、自动 / 手动时长和 Loop。两条路径的 Prompt 模型、controller、预设 wrapper、锁和提交契约必须分别维护,不得交叉复用业务状态。
SFX V2 已完成产品与技术口径冻结,T0 已通过,T1、T2 已完成,可以继续进入 T3、T4;这不表示功能已上线。T2 已落地登录态一键优化 BFF 和仅生成流水线内部可见的 Worker 翻译 service翻译到正式 Worker、ElevenLabs、计费、持久化和 External v1 的接线仍属于 T5。在正式切换完成前,当前运行代码仍是 Vidu SFX V1 行为。历史 Vidu 素材继续只读展示;重绘时使用历史用户 Prompt 打开 SFX V2 面板,新任务统一走 ElevenLabs,不回退 Vidu。
SFX V2 已完成产品与技术口径冻结,T0 已通过,T1、T2、T3、T4 已完成,可以继续进入 T5;这不表示功能已上线。T2 已落地登录态一键优化 BFF 和仅生成流水线内部可见的 Worker 翻译 serviceT3 已落地 ElevenLabs 直接二进制 adapterT4 已落地 dialog-scoped 前端 controller、52 预设、优化 / 撤销、自动 / 手动时长与 Loop UI。翻译到正式 Worker、ElevenLabs、计费、持久化和 External v1 的接线仍属于 T5。在正式切换完成前,当前正式生成代码仍是 Vidu SFX V1 行为T4 前端也不得单独发布。历史 Vidu 素材继续只读展示;重绘时使用历史用户 Prompt 打开 SFX V2 面板,新任务统一走 ElevenLabs,不回退 Vidu。
## 入口与交互
@@ -0,0 +1,489 @@
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
} from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
export type AudioPromptPreset = {
id: string;
group: string;
label: string;
};
/** 多份等宽队列拼接实现无缝循环:首尾之间不跳回也不留白。 */
const PRESET_QUEUE_COPIES = 3;
/** 初始 scrollLeft 正对中间队列,因此它同时承担唯一的键盘与屏幕阅读器入口。 */
const PRESET_ACCESSIBLE_QUEUE_INDEX = 1;
/** 默认缓慢循环与 hover 加速的速度,单位 px/s。 */
const PRESET_BASE_VELOCITY = 26;
/**
* 加速档按真机实测下调到原值 340 的 70%。左右 15% 控制区与左右箭头共用同一个速度:
* 需求要求“箭头和滚动栏两边都有加速滚动”,两者相邻,拆成两个速度会让指针在箭头与
* 控制区之间移动时出现速度突变。
*/
const PRESET_FAST_VELOCITY = 238;
/** 左右控制区各占 viewport 宽度的 15%,中间 70% 为暂停区。 */
const PRESET_SIDE_ZONE_RATIO = 0.15;
/** 箭头点击的一次离散滚动量,按 viewport 宽度比例计算。 */
const PRESET_ARROW_STEP_RATIO = 0.6;
/** 单帧最大步进:标签页被挂起后回到前台时不产生一次巨大跳变。 */
const PRESET_MAX_FRAME_MS = 100;
/**
* 触摸抬手后判定“惯性已经停下”的空闲窗口。原生惯性可能持续一两秒,固定等待再恢复
* rAF 一定会打断它,所以这里等的是滚动事件停止,而不是一个固定时长。
*/
const PRESET_TOUCH_IDLE_MS = 160;
const PRESET_REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/** 桌面 hover 控制区只在真正支持悬停的精确指针设备上启用。 */
const PRESET_HOVER_CONTROL_QUERY = '(hover: hover) and (pointer: fine)';
type AudioPresetZone = 'left' | 'center' | 'right' | null;
function readsMediaQueryMatches(query: string) {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(query).matches;
}
function useMediaQueryMatches(query: string) {
const [matches, setMatches] = useState(() => readsMediaQueryMatches(query));
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return undefined;
}
const mediaQuery = window.matchMedia(query);
// 运行中切换(系统偏好变化、外接鼠标插拔)也要立即生效,不能只读初值。
setMatches(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [query]);
return matches;
}
export function ImageCanvasAudioPresetMarquee<
TPreset extends AudioPromptPreset,
>({
presets,
isLocked,
onSelectPreset,
headerTrailing,
getPresetClassName,
}: {
presets: readonly TPreset[];
isLocked: boolean;
onSelectPreset: (preset: TPreset) => void;
/** 标题行右端的展示位。字数计数要和「预设」同排,但它属于 composer 的状态。 */
headerTrailing?: ReactNode;
getPresetClassName: (preset: TPreset) => string;
}) {
const trackId = useId();
const [isExpanded, setIsExpanded] = useState(false);
const [zone, setZone] = useState<AudioPresetZone>(null);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const [isPageVisible, setIsPageVisible] = useState(
() =>
typeof document === 'undefined' || document.visibilityState !== 'hidden',
);
const prefersReducedMotion = useMediaQueryMatches(
PRESET_REDUCED_MOTION_QUERY,
);
const supportsHoverControls = useMediaQueryMatches(
PRESET_HOVER_CONTROL_QUERY,
);
const [isTouchScrolling, setIsTouchScrolling] = useState(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const queueRef = useRef<HTMLDivElement | null>(null);
const queueWidthRef = useRef(0);
const frameRef = useRef<number | null>(null);
const frameTimestampRef = useRef<number | null>(null);
const touchSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// 归位只按实测的单份队列宽度取模,不硬编码 chip 或轨道宽度。
const normalizeScrollPosition = useCallback(() => {
const viewport = viewportRef.current;
const queueWidth = queueWidthRef.current;
if (!viewport || queueWidth <= 0) {
return;
}
if (viewport.scrollLeft < queueWidth * 0.5) {
viewport.scrollLeft += queueWidth;
return;
}
if (viewport.scrollLeft > queueWidth * 1.5) {
viewport.scrollLeft -= queueWidth;
}
}, []);
const velocity = (() => {
if (prefersReducedMotion || isLocked || !isExpanded || !isPageVisible) {
return 0;
}
if (isTouchScrolling || isKeyboardFocused || zone === 'center') {
return 0;
}
if (zone === 'left') {
return -PRESET_FAST_VELOCITY;
}
if (zone === 'right') {
return PRESET_FAST_VELOCITY;
}
return PRESET_BASE_VELOCITY;
})();
useEffect(() => {
if (velocity === 0) {
return undefined;
}
const step = (timestamp: number) => {
const previousTimestamp = frameTimestampRef.current ?? timestamp;
const deltaMs = Math.min(
Math.max(timestamp - previousTimestamp, 0),
PRESET_MAX_FRAME_MS,
);
frameTimestampRef.current = timestamp;
const viewport = viewportRef.current;
if (viewport) {
viewport.scrollLeft += (velocity * deltaMs) / 1000;
normalizeScrollPosition();
}
frameRef.current = requestAnimationFrame(step);
};
frameRef.current = requestAnimationFrame(step);
return () => {
frameTimestampRef.current = null;
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [normalizeScrollPosition, velocity]);
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const handleVisibilityChange = () => {
setIsPageVisible(document.visibilityState !== 'hidden');
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
useEffect(() => {
const queue = queueRef.current;
if (!queue || typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(() => {
const nextQueueWidth = queue.getBoundingClientRect().width;
if (nextQueueWidth <= 0) {
return;
}
const previousQueueWidth = queueWidthRef.current;
queueWidthRef.current = nextQueueWidth;
const viewport = viewportRef.current;
if (!viewport) {
return;
}
// 尺寸变化后按新宽度重新建立基准,避免停在旧队列的相位上。
if (previousQueueWidth <= 0) {
viewport.scrollLeft = nextQueueWidth;
return;
}
normalizeScrollPosition();
});
observer.observe(queue);
return () => {
observer.disconnect();
};
}, [isExpanded, normalizeScrollPosition]);
useEffect(
() => () => {
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
}
},
[],
);
useEffect(() => {
if (isExpanded && !isLocked && supportsHoverControls) {
return;
}
// 收起、锁定或失去 hover 能力时回到默认状态。最后一种尤其重要:没有 hover 的
// 设备不会再有 pointerleave,残留的 `center` 会让循环永久停住。
setZone(null);
setIsKeyboardFocused(false);
}, [isExpanded, isLocked, supportsHoverControls]);
const handleViewportPointerMove = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
// 只在真正支持悬停的精确指针设备上启用;触摸指针再挡一道。控制区按 viewport
// 相对 X 计算,不在 chip 上方覆盖三个透明 div。
if (!supportsHoverControls || event.pointerType === 'touch' || isLocked) {
return;
}
const bounds = event.currentTarget.getBoundingClientRect();
if (bounds.width <= 0) {
return;
}
const ratio = (event.clientX - bounds.left) / bounds.width;
// 位置算不出来时保持当前区域:默认落到“中间暂停”会让指针一进入轨道就停住循环。
if (!Number.isFinite(ratio)) {
return;
}
if (ratio < PRESET_SIDE_ZONE_RATIO) {
setZone('left');
return;
}
if (ratio > 1 - PRESET_SIDE_ZONE_RATIO) {
setZone('right');
return;
}
setZone('center');
};
const handleViewportPointerLeave = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
};
const armTouchSettleWatch = () => {
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
}
touchSettleTimerRef.current = setTimeout(() => {
touchSettleTimerRef.current = null;
setIsTouchScrolling(false);
}, PRESET_TOUCH_IDLE_MS);
};
const beginTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch') {
return;
}
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
touchSettleTimerRef.current = null;
}
setIsTouchScrolling(true);
};
const endTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch') {
return;
}
// 抬手只是开始等待:原生惯性可能还要跑一两秒,真正的恢复条件是滚动停止。
armTouchSettleWatch();
};
const handleViewportScroll = () => {
// 只有在等待惯性稳定的窗口内才顺延;否则收到的是 rAF 自己写 scrollLeft 触发的
// scroll,会把恢复条件永远推后。
if (touchSettleTimerRef.current === null) {
return;
}
armTouchSettleWatch();
};
const scrollByArrow = (direction: -1 | 1) => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const step =
viewport.getBoundingClientRect().width * PRESET_ARROW_STEP_RATIO;
// 离散滚动不使用平滑动画,供触摸和键盘使用。
viewport.scrollLeft += direction * (step || PRESET_FAST_VELOCITY);
normalizeScrollPosition();
};
return (
<section className="image-canvas-editor__background-music-presets">
<div className="image-canvas-editor__background-music-presets-header">
<span className="image-canvas-editor__background-music-presets-title">
</span>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-presets-toggle"
// 图标化后文案不再进入可访问名,必须由 aria-label 承担。
aria-label={isExpanded ? '收起' : '展开'}
aria-expanded={isExpanded}
aria-controls={trackId}
disabled={isLocked}
onClick={() => setIsExpanded((expanded) => !expanded)}
>
{isExpanded ? (
<ChevronUp aria-hidden="true" />
) : (
<ChevronDown aria-hidden="true" />
)}
</PlatformActionButton>
{headerTrailing}
</div>
{isExpanded ? (
<div
id={trackId}
className="image-canvas-editor__background-music-presets-track"
>
<button
type="button"
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--left"
aria-label="预设向左滚动"
disabled={isLocked}
onPointerEnter={(event) => {
if (!supportsHoverControls || event.pointerType === 'touch') {
return;
}
setZone('left');
}}
onPointerLeave={(event) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
}}
onClick={() => scrollByArrow(-1)}
>
<ChevronLeft aria-hidden="true" />
</button>
<div
ref={viewportRef}
className="image-canvas-editor__background-music-presets-viewport"
onPointerMove={handleViewportPointerMove}
onPointerLeave={handleViewportPointerLeave}
onPointerDown={beginTouchScroll}
onPointerUp={endTouchScroll}
onPointerCancel={endTouchScroll}
onScroll={handleViewportScroll}
onFocus={() => setIsKeyboardFocused(true)}
onBlur={() => setIsKeyboardFocused(false)}
>
{Array.from({ length: PRESET_QUEUE_COPIES }, (_, copyIndex) => {
const isAccessibleQueue =
copyIndex === PRESET_ACCESSIBLE_QUEUE_INDEX;
return (
<div
key={copyIndex}
ref={isAccessibleQueue ? queueRef : undefined}
className="image-canvas-editor__background-music-presets-queue"
// 中间队列是唯一真实控件队列;首尾只负责无缝循环视觉,不能持有焦点。
aria-hidden={isAccessibleQueue ? undefined : true}
>
{presets.map((preset) => {
const className = getPresetClassName(preset);
if (isAccessibleQueue) {
return (
<button
key={preset.id}
type="button"
className={className}
disabled={isLocked}
onClick={() => onSelectPreset(preset)}
>
{preset.label}
</button>
);
}
return (
<span
key={preset.id}
className={`${className}${
isLocked
? ' image-canvas-editor__background-music-preset--disabled'
: ''
}`}
onClick={() => {
if (!isLocked) {
onSelectPreset(preset);
}
}}
>
{preset.label}
</span>
);
})}
</div>
);
})}
</div>
<button
type="button"
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--right"
aria-label="预设向右滚动"
disabled={isLocked}
onPointerEnter={(event) => {
if (!supportsHoverControls || event.pointerType === 'touch') {
return;
}
setZone('right');
}}
onPointerLeave={(event) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
}}
onClick={() => scrollByArrow(1)}
>
<ChevronRight aria-hidden="true" />
</button>
<div
className={
zone
? `image-canvas-editor__background-music-presets-hairline image-canvas-editor__background-music-presets-hairline--${zone}`
: 'image-canvas-editor__background-music-presets-hairline'
}
data-zone={zone ?? 'none'}
aria-hidden="true"
/>
</div>
) : null}
</section>
);
}
@@ -1,87 +1,8 @@
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
} from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { ImageCanvasAudioPresetMarquee } from './ImageCanvasAudioPresetMarquee';
import type { BackgroundMusicPromptPreset } from './ImageCanvasBackgroundMusicPresetModel';
/** 多份等宽队列拼接实现无缝循环:首尾之间不跳回也不留白。 */
const PRESET_QUEUE_COPIES = 3;
/** 初始 scrollLeft 正对中间队列,因此它同时承担唯一的键盘与屏幕阅读器入口。 */
const PRESET_ACCESSIBLE_QUEUE_INDEX = 1;
/** 默认缓慢循环与 hover 加速的速度,单位 px/s。 */
const PRESET_BASE_VELOCITY = 26;
/**
* 加速档按真机实测下调到原值 340 的 70%。左右 15% 控制区与左右箭头共用同一个速度:
* 需求要求“箭头和滚动栏两边都有加速滚动”,两者相邻,拆成两个速度会让指针在箭头与
* 控制区之间移动时出现速度突变。
*/
const PRESET_FAST_VELOCITY = 238;
/** 左右控制区各占 viewport 宽度的 15%,中间 70% 为暂停区。 */
const PRESET_SIDE_ZONE_RATIO = 0.15;
/** 箭头点击的一次离散滚动量,按 viewport 宽度比例计算。 */
const PRESET_ARROW_STEP_RATIO = 0.6;
/** 单帧最大步进:标签页被挂起后回到前台时不产生一次巨大跳变。 */
const PRESET_MAX_FRAME_MS = 100;
/**
* 触摸抬手后判定“惯性已经停下”的空闲窗口。原生惯性可能持续一两秒,固定等待再恢复
* rAF 一定会打断它,所以这里等的是滚动事件停止,而不是一个固定时长。
*/
const PRESET_TOUCH_IDLE_MS = 160;
const PRESET_REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/** 桌面 hover 控制区只在真正支持悬停的精确指针设备上启用。 */
const PRESET_HOVER_CONTROL_QUERY = '(hover: hover) and (pointer: fine)';
type BackgroundMusicPresetZone = 'left' | 'center' | 'right' | null;
function readsMediaQueryMatches(query: string) {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(query).matches;
}
function useMediaQueryMatches(query: string) {
const [matches, setMatches] = useState(() => readsMediaQueryMatches(query));
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return undefined;
}
const mediaQuery = window.matchMedia(query);
// 运行中切换(系统偏好变化、外接鼠标插拔)也要立即生效,不能只读初值。
setMatches(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [query]);
return matches;
}
export function ImageCanvasBackgroundMusicPresetMarquee({
presets,
isLocked,
@@ -91,390 +12,17 @@ export function ImageCanvasBackgroundMusicPresetMarquee({
presets: readonly BackgroundMusicPromptPreset[];
isLocked: boolean;
onSelectPreset: (preset: BackgroundMusicPromptPreset) => void;
/** 标题行右端的展示位。字数计数要和「预设」同排,但它属于 composer 的状态。 */
headerTrailing?: ReactNode;
}) {
const trackId = useId();
const [isExpanded, setIsExpanded] = useState(false);
const [zone, setZone] = useState<BackgroundMusicPresetZone>(null);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const [isPageVisible, setIsPageVisible] = useState(
() =>
typeof document === 'undefined' || document.visibilityState !== 'hidden',
);
const prefersReducedMotion = useMediaQueryMatches(
PRESET_REDUCED_MOTION_QUERY,
);
const supportsHoverControls = useMediaQueryMatches(
PRESET_HOVER_CONTROL_QUERY,
);
const [isTouchScrolling, setIsTouchScrolling] = useState(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const queueRef = useRef<HTMLDivElement | null>(null);
const queueWidthRef = useRef(0);
const frameRef = useRef<number | null>(null);
const frameTimestampRef = useRef<number | null>(null);
const touchSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// 归位只按实测的单份队列宽度取模,不硬编码 chip 或轨道宽度。
const normalizeScrollPosition = useCallback(() => {
const viewport = viewportRef.current;
const queueWidth = queueWidthRef.current;
if (!viewport || queueWidth <= 0) {
return;
}
if (viewport.scrollLeft < queueWidth * 0.5) {
viewport.scrollLeft += queueWidth;
return;
}
if (viewport.scrollLeft > queueWidth * 1.5) {
viewport.scrollLeft -= queueWidth;
}
}, []);
const velocity = (() => {
if (prefersReducedMotion || isLocked || !isExpanded || !isPageVisible) {
return 0;
}
if (isTouchScrolling || isKeyboardFocused || zone === 'center') {
return 0;
}
if (zone === 'left') {
return -PRESET_FAST_VELOCITY;
}
if (zone === 'right') {
return PRESET_FAST_VELOCITY;
}
return PRESET_BASE_VELOCITY;
})();
useEffect(() => {
if (velocity === 0) {
return undefined;
}
const step = (timestamp: number) => {
const previousTimestamp = frameTimestampRef.current ?? timestamp;
const deltaMs = Math.min(
Math.max(timestamp - previousTimestamp, 0),
PRESET_MAX_FRAME_MS,
);
frameTimestampRef.current = timestamp;
const viewport = viewportRef.current;
if (viewport) {
viewport.scrollLeft += (velocity * deltaMs) / 1000;
normalizeScrollPosition();
}
frameRef.current = requestAnimationFrame(step);
};
frameRef.current = requestAnimationFrame(step);
return () => {
frameTimestampRef.current = null;
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [normalizeScrollPosition, velocity]);
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const handleVisibilityChange = () => {
setIsPageVisible(document.visibilityState !== 'hidden');
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
useEffect(() => {
const queue = queueRef.current;
if (!queue || typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(() => {
const nextQueueWidth = queue.getBoundingClientRect().width;
if (nextQueueWidth <= 0) {
return;
}
const previousQueueWidth = queueWidthRef.current;
queueWidthRef.current = nextQueueWidth;
const viewport = viewportRef.current;
if (!viewport) {
return;
}
// 尺寸变化后按新宽度重新建立基准,避免停在旧队列的相位上。
if (previousQueueWidth <= 0) {
viewport.scrollLeft = nextQueueWidth;
return;
}
normalizeScrollPosition();
});
observer.observe(queue);
return () => {
observer.disconnect();
};
}, [isExpanded, normalizeScrollPosition]);
useEffect(
() => () => {
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
}
},
[],
);
useEffect(() => {
if (isExpanded && !isLocked && supportsHoverControls) {
return;
}
// 收起、锁定或失去 hover 能力时回到默认状态。最后一种尤其重要:没有 hover 的
// 设备不会再有 pointerleave,残留的 `center` 会让循环永久停住。
setZone(null);
setIsKeyboardFocused(false);
}, [isExpanded, isLocked, supportsHoverControls]);
const handleViewportPointerMove = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
// 只在真正支持悬停的精确指针设备上启用;触摸指针再挡一道。控制区按 viewport
// 相对 X 计算,不在 chip 上方覆盖三个透明 div。
if (!supportsHoverControls || event.pointerType === 'touch' || isLocked) {
return;
}
const bounds = event.currentTarget.getBoundingClientRect();
if (bounds.width <= 0) {
return;
}
const ratio = (event.clientX - bounds.left) / bounds.width;
// 位置算不出来时保持当前区域:默认落到“中间暂停”会让指针一进入轨道就停住循环。
if (!Number.isFinite(ratio)) {
return;
}
if (ratio < PRESET_SIDE_ZONE_RATIO) {
setZone('left');
return;
}
if (ratio > 1 - PRESET_SIDE_ZONE_RATIO) {
setZone('right');
return;
}
setZone('center');
};
const handleViewportPointerLeave = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
};
const armTouchSettleWatch = () => {
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
}
touchSettleTimerRef.current = setTimeout(() => {
touchSettleTimerRef.current = null;
setIsTouchScrolling(false);
}, PRESET_TOUCH_IDLE_MS);
};
const beginTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch') {
return;
}
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
touchSettleTimerRef.current = null;
}
setIsTouchScrolling(true);
};
const endTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch') {
return;
}
// 抬手只是开始等待:原生惯性可能还要跑一两秒,真正的恢复条件是滚动停止。
armTouchSettleWatch();
};
const handleViewportScroll = () => {
// 只有在等待惯性稳定的窗口内才顺延;否则收到的是 rAF 自己写 scrollLeft 触发的
// scroll,会把恢复条件永远推后。
if (touchSettleTimerRef.current === null) {
return;
}
armTouchSettleWatch();
};
const scrollByArrow = (direction: -1 | 1) => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const step =
viewport.getBoundingClientRect().width * PRESET_ARROW_STEP_RATIO;
// 离散滚动不使用平滑动画,供触摸和键盘使用。
viewport.scrollLeft += direction * (step || PRESET_FAST_VELOCITY);
normalizeScrollPosition();
};
return (
<section className="image-canvas-editor__background-music-presets">
<div className="image-canvas-editor__background-music-presets-header">
<span className="image-canvas-editor__background-music-presets-title">
</span>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-presets-toggle"
// 图标化后文案不再进入可访问名,必须由 aria-label 承担。
aria-label={isExpanded ? '收起' : '展开'}
aria-expanded={isExpanded}
aria-controls={trackId}
disabled={isLocked}
onClick={() => setIsExpanded((expanded) => !expanded)}
>
{isExpanded ? (
<ChevronUp aria-hidden="true" />
) : (
<ChevronDown aria-hidden="true" />
)}
</PlatformActionButton>
{headerTrailing}
</div>
{isExpanded ? (
<div
id={trackId}
className="image-canvas-editor__background-music-presets-track"
>
<button
type="button"
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--left"
aria-label="预设向左滚动"
disabled={isLocked}
onPointerEnter={(event) => {
if (!supportsHoverControls || event.pointerType === 'touch') {
return;
}
setZone('left');
}}
onPointerLeave={(event) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
}}
onClick={() => scrollByArrow(-1)}
>
<ChevronLeft aria-hidden="true" />
</button>
<div
ref={viewportRef}
className="image-canvas-editor__background-music-presets-viewport"
onPointerMove={handleViewportPointerMove}
onPointerLeave={handleViewportPointerLeave}
onPointerDown={beginTouchScroll}
onPointerUp={endTouchScroll}
onPointerCancel={endTouchScroll}
onScroll={handleViewportScroll}
onFocus={() => setIsKeyboardFocused(true)}
onBlur={() => setIsKeyboardFocused(false)}
>
{Array.from({ length: PRESET_QUEUE_COPIES }, (_, copyIndex) => {
const isAccessibleQueue =
copyIndex === PRESET_ACCESSIBLE_QUEUE_INDEX;
return (
<div
key={copyIndex}
ref={isAccessibleQueue ? queueRef : undefined}
className="image-canvas-editor__background-music-presets-queue"
// 中间队列是唯一真实控件队列;首尾只负责无缝循环视觉,不能持有焦点。
aria-hidden={isAccessibleQueue ? undefined : true}
>
{presets.map((preset) => {
const className = `image-canvas-editor__background-music-preset image-canvas-editor__background-music-preset--${preset.group}`;
if (isAccessibleQueue) {
return (
<button
key={preset.id}
type="button"
className={className}
disabled={isLocked}
onClick={() => onSelectPreset(preset)}
>
{preset.label}
</button>
);
}
return (
<span
key={preset.id}
className={`${className}${
isLocked
? ' image-canvas-editor__background-music-preset--disabled'
: ''
}`}
onClick={() => {
if (!isLocked) {
onSelectPreset(preset);
}
}}
>
{preset.label}
</span>
);
})}
</div>
);
})}
</div>
<button
type="button"
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--right"
aria-label="预设向右滚动"
disabled={isLocked}
onPointerEnter={(event) => {
if (!supportsHoverControls || event.pointerType === 'touch') {
return;
}
setZone('right');
}}
onPointerLeave={(event) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
}}
onClick={() => scrollByArrow(1)}
>
<ChevronRight aria-hidden="true" />
</button>
<div
className={
zone
? `image-canvas-editor__background-music-presets-hairline image-canvas-editor__background-music-presets-hairline--${zone}`
: 'image-canvas-editor__background-music-presets-hairline'
}
data-zone={zone ?? 'none'}
aria-hidden="true"
/>
</div>
) : null}
</section>
<ImageCanvasAudioPresetMarquee
presets={presets}
isLocked={isLocked}
onSelectPreset={onSelectPreset}
headerTrailing={headerTrailing}
getPresetClassName={(preset) =>
`image-canvas-editor__background-music-preset image-canvas-editor__background-music-preset--${preset.group}`
}
/>
);
}
@@ -1130,7 +1130,10 @@ describe('ImageCanvasEditorModel', () => {
prompt: '金币掉落叮当声',
status: 'idle',
composerOpen: true,
soundDurationSeconds: 8,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'auto',
soundDurationSeconds: 7.3,
soundLoop: true,
generatedLayerId: 'layer-audio',
placeholder: {
x: 100,
@@ -1154,7 +1157,10 @@ describe('ImageCanvasEditorModel', () => {
id: 'generation-dialog-audio',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
soundDurationSeconds: 8,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'auto',
soundDurationSeconds: 7.3,
soundLoop: true,
generatedLayerId: 'layer-audio',
});
});
@@ -1360,11 +1360,27 @@ export function hydrateCanvasGenerationDialog(
characterAnimationResult: hydrateCharacterAnimationResult(
snapshot.characterAnimationResult,
),
soundModel: snapshot.soundModel === 'audio1.0' ? 'audio1.0' : undefined,
soundDurationSeconds: soundDurationOrDefault(
snapshot.soundDurationSeconds,
snapshot.audioDurationSeconds,
),
soundModel:
snapshot.mode === 'audio-sound-effect'
? EDITOR_SOUND_EFFECT_MODEL
: undefined,
soundDurationMode:
snapshot.mode === 'audio-sound-effect'
? snapshot.soundDurationMode === 'auto'
? 'auto'
: 'manual'
: undefined,
soundDurationSeconds:
snapshot.mode === 'audio-sound-effect'
? soundDurationOrDefault(
snapshot.soundDurationSeconds,
snapshot.audioDurationSeconds,
)
: undefined,
soundLoop:
snapshot.mode === 'audio-sound-effect'
? snapshot.soundLoop === true
: undefined,
makeInstrumental:
typeof snapshot.makeInstrumental === 'boolean'
? snapshot.makeInstrumental
@@ -2020,7 +2036,10 @@ export function soundDurationOrDefault(
if (!resolvedValue) {
return undefined;
}
return Math.min(10, Math.max(2, Math.round(resolvedValue)));
return Math.min(
SOUND_EFFECT_DURATION_MAX_SECONDS,
Math.max(SOUND_EFFECT_DURATION_MIN_SECONDS, resolvedValue),
);
}
export function stringOrNull(value: unknown) {
@@ -1,4 +1,8 @@
import type { EditorSoundEffectGenerationMetadataV2 } from '../../../packages/shared/src/contracts/editorAudio';
import type {
EditorSoundEffectDurationMode,
EditorSoundEffectGenerationMetadataV2,
EditorSoundEffectModel,
} from '../../../packages/shared/src/contracts/editorAudio';
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
@@ -249,9 +253,10 @@ export type GenerateDialogState = {
characterAnimationFrameCount?: EditorCharacterAnimationFrameCount;
characterAnimationDurationSeconds?: 4 | 5 | 6;
characterAnimationResult?: EditorCharacterAnimationGenerationResult;
soundModel?: 'audio1.0';
// 中文注释:Vidu 文生音频 duration 只允许 2-10 秒,默认 5 秒。
soundModel?: EditorSoundEffectModel | 'audio1.0';
soundDurationMode?: EditorSoundEffectDurationMode;
soundDurationSeconds?: number;
soundLoop?: boolean;
makeInstrumental?: boolean;
audioDurationSeconds?: number | null;
aspectRatio?: string;
@@ -21,6 +21,10 @@ import type {
BackgroundMusicPromptAssistComposerController,
BackgroundMusicPromptAssistDialogState,
} from './useImageCanvasBackgroundMusicPromptAssist';
import type {
SoundEffectPromptAssistComposerController,
SoundEffectPromptAssistDialogState,
} from './useImageCanvasSoundEffectPromptAssist';
function mockStateSetter<T>() {
return vi.fn() as unknown as Dispatch<SetStateAction<T>>;
@@ -54,6 +58,7 @@ function createComposerProps(
isPickingUiDesignSpecFromCanvas: false,
isPickingPublicationReferenceFromCanvas: false,
generateDialog,
soundEffectPromptAssist: createSoundEffectPromptAssistStub(),
updateCanvasGenerationDialogById: vi.fn(),
generationComposerStyle: { left: 320, top: 240 },
iconComposerStyle: { left: 320, top: 240, width: '32rem' },
@@ -172,6 +177,32 @@ function createBackgroundMusicPromptAssistStub(
};
}
function createSoundEffectPromptAssistStub(
assistState: Partial<SoundEffectPromptAssistDialogState> = {},
overrides: Partial<SoundEffectPromptAssistComposerController> = {},
): SoundEffectPromptAssistComposerController {
return {
getDialogState: (dialogId: string) => ({
dialogId,
status: 'idle' as const,
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: null,
...assistState,
}),
optimizePrompt: vi.fn(async () => ({
started: true,
applied: true,
reason: 'applied' as const,
errorMessage: null,
})),
preparePreset: vi.fn(() => ''),
undoPrompt: vi.fn(() => true),
...overrides,
};
}
function renderBackgroundMusicComposer({
dialog = createBackgroundMusicDialog(),
promptAssist = createBackgroundMusicPromptAssistStub(),
@@ -877,21 +908,40 @@ describe('ImageCanvasGenerationComposerView', () => {
);
});
it('生成游戏音效面板使用 Vidu duration 参数且不再显示类型和 BPM', () => {
it('生成游戏音效面板展示 SFX V2 预设、参数和 ElevenLabs', () => {
const onSubmitImageGeneration = vi.fn();
function AudioHarness() {
const [dialog, setDialog] = useState<GenerateDialogState>({
id: 'dialog-sfx',
mode: 'audio-sound-effect',
prompt: '',
prompt: '开门声',
status: 'idle',
composerOpen: true,
soundModel: 'audio1.0',
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 5,
soundLoop: false,
});
const updateCanvasGenerationDialogById = (
dialogId: string,
updater: (
current: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
) => {
setDialog((current) => {
if (current.id !== dialogId) {
return current;
}
return updater(current as CanvasGenerationDialogState) ?? current;
});
};
return (
<>
<ImageCanvasGenerationComposerView
{...createComposerProps(dialog, { onSubmitImageGeneration })}
{...createComposerProps(dialog, {
onSubmitImageGeneration,
updateCanvasGenerationDialogById,
})}
setGenerateDialog={
setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>
}
@@ -914,36 +964,42 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(footer?.children[0]?.className).toContain(
'image-canvas-editor__option-popover-anchor--dimensions',
);
expect(footer?.children[1]?.className).toContain(
expect(footer?.children[2]?.className).toContain(
'image-canvas-editor__option-popover-anchor--model',
);
expect(footer?.children[2]?.className).toContain(
expect(footer?.children[3]?.className).toContain(
'image-canvas-editor__generation-submit',
);
const prompt = within(panel).getByRole('textbox', { name: 'prompt' });
expect(prompt.className).toContain('auto-grow-text-area');
expect(prompt.className).not.toContain('platform-text-field');
expect(screen.getByLabelText('当前音效模型').textContent).toBe('audio1.0');
expect(screen.getByLabelText('当前音效模型').textContent).toBe(
'eleven_text_to_sound_v2',
);
const soundModelButton = within(panel).getByRole('button', {
name: '音效模型 Vidu',
name: '音效模型 ElevenLabs',
});
expect((soundModelButton as HTMLButtonElement).disabled).toBe(true);
expect(within(panel).getByText('Vidu')).toBeTruthy();
expect(within(panel).getByText('ElevenLabs')).toBeTruthy();
expect(within(panel).queryByText(/Suno/u)).toBeNull();
expect(
within(panel).getByRole('button', { name: '音效时长 5秒' }),
).toBeTruthy();
expect(within(panel).queryByText('单次')).toBeNull();
expect(within(panel).queryByText('循环')).toBeNull();
expect(
within(panel)
.getByRole('button', { name: 'Loop' })
.getAttribute('aria-pressed'),
).toBe('false');
expect(within(panel).queryByText(/BPM/u)).toBeNull();
expect(within(panel).queryByRole('button', { name: 'AI 补全' })).toBeNull();
expect(
within(panel).queryByRole('button', { name: '一键简化' }),
).toBeNull();
expect(
within(panel).queryByRole('button', { name: '一键优化' }),
).toBeNull();
expect(within(panel).queryByRole('button', { name: '展开' })).toBeNull();
within(panel).getByRole('button', { name: '一键优化' }),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '展开' })).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '生成游戏音效' }).textContent,
).toBe('生成5泥点');
@@ -953,30 +1009,92 @@ describe('ImageCanvasGenerationComposerView', () => {
);
const optionPanel = screen.getByRole('menu', { name: '音效时长选项' });
const durationSlider = within(optionPanel).getByRole('slider', {
name: '音效时长',
name: '手动音效时长',
}) as HTMLInputElement;
expect(durationSlider.min).toBe('2');
expect(durationSlider.max).toBe('10');
expect(durationSlider.step).toBe('1');
expect(durationSlider.min).toBe('0.5');
expect(durationSlider.max).toBe('30');
expect(durationSlider.step).toBe('0.1');
expect(durationSlider.value).toBe('5');
expect(
within(optionPanel).queryByRole('button', { name: '时长 2秒' }),
).toBeNull();
expect(within(optionPanel).queryByText(/BPM/u)).toBeNull();
const automaticDuration = within(optionPanel).getByRole('checkbox', {
name: '自动时长',
}) as HTMLInputElement;
fireEvent.click(automaticDuration);
expect(
screen.getByRole('button', { name: '音效时长 自动时长' }),
).toBeTruthy();
expect(durationSlider.disabled).toBe(true);
fireEvent.click(automaticDuration);
expect(durationSlider.disabled).toBe(false);
expect(durationSlider.value).toBe('5');
fireEvent.change(durationSlider, { target: { value: '8' } });
expect(screen.getByLabelText('当前音效时长').textContent).toBe('8');
expect(screen.getByRole('button', { name: '音效时长 8秒' })).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: 'Loop' }));
fireEvent.submit(panel);
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'audio-sound-effect',
soundDurationSeconds: 8,
soundLoop: true,
}),
);
});
it('SFX 优化中保留撤销按钮并只锁当前 dialog 的全部控件', () => {
renderComposer(
{
id: 'dialog-sfx-locked',
mode: 'audio-sound-effect',
prompt: '金币落地声',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 5,
soundLoop: false,
},
{
soundEffectPromptAssist: createSoundEffectPromptAssistStub({
status: 'optimizing',
operationId: 'operation-sfx-1',
temporaryPromptSnapshot: '金币落地声',
}),
},
);
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
expect(panel.getAttribute('aria-busy')).toBe('true');
expect(
(
within(panel).getByRole('textbox', {
name: 'prompt',
}) as HTMLTextAreaElement
).readOnly,
).toBe(true);
for (const name of [
'展开',
'一键优化',
'撤销',
'音效时长 5秒',
'Loop',
'生成游戏音效',
]) {
expect(
(within(panel).getByRole('button', { name }) as HTMLButtonElement)
.disabled,
).toBe(true);
}
expect(within(panel).getByText('优化中')).toBeTruthy();
expect(within(panel).queryByText('Suno')).toBeNull();
expect(within(panel).queryByRole('button', { name: 'AI 补全' })).toBeNull();
});
it('生成游戏背景音乐面板只展示 gpt_description_prompt 并固定隐藏 instrumental 开关', () => {
renderComposer(
{
@@ -1397,13 +1515,14 @@ describe('ImageCanvasGenerationComposerView', () => {
).toBeNull();
});
it('缺少稳定 ID 和 BGM controller 不影响 SFX 正常渲染及默认时长', () => {
it('BGM controller 不影响带稳定 ID 的 SFX 正常渲染及默认时长', () => {
renderComposer({
id: 'dialog-sfx',
mode: 'audio-sound-effect',
prompt: '',
status: 'idle',
composerOpen: true,
soundModel: 'audio1.0',
soundModel: 'eleven_text_to_sound_v2',
});
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
@@ -1460,11 +1579,12 @@ describe('ImageCanvasGenerationComposerView', () => {
operationId: 'operation-1',
});
const soundEffectDialog: GenerateDialogState = {
id: 'dialog-sfx',
mode: 'audio-sound-effect',
prompt: '开门声',
status: 'idle',
composerOpen: true,
soundModel: 'audio1.0',
soundModel: 'eleven_text_to_sound_v2',
soundDurationSeconds: 5,
};
const view = renderComposer(soundEffectDialog, {
@@ -21,6 +21,10 @@ import {
useState,
} from 'react';
import {
SOUND_EFFECT_DURATION_MAX_SECONDS,
SOUND_EFFECT_DURATION_MIN_SECONDS,
} from '../../../packages/shared/src/contracts/editorAudio';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import { PlatformActionButton } from '../common/PlatformActionButton';
import {
@@ -83,9 +87,23 @@ import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPubli
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { ImageCanvasSoundEffectPresetMarquee } from './ImageCanvasSoundEffectPresetMarquee';
import {
appendSoundEffectPromptPreset,
SOUND_EFFECT_PROMPT_PRESETS,
type SoundEffectPromptPreset,
} from './ImageCanvasSoundEffectPresetModel';
import {
canGenerateSoundEffectFromPrompt,
canOptimizeSoundEffectPrompt,
countSoundEffectPromptCodePoints,
SOUND_EFFECT_PROMPT_MAX_CODE_POINTS,
toSoundEffectGenerationDialog,
} from './ImageCanvasSoundEffectPromptModel';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import type { BackgroundMusicPromptAssistComposerController } from './useImageCanvasBackgroundMusicPromptAssist';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
import type { SoundEffectPromptAssistComposerController } from './useImageCanvasSoundEffectPromptAssist';
type ImageCanvasGenerationComposerViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
@@ -111,6 +129,7 @@ type ImageCanvasGenerationComposerViewProps = {
hasPendingImageReferenceUploads?: boolean;
generateDialog: GenerateDialogState | null;
backgroundMusicPromptAssist?: BackgroundMusicPromptAssistComposerController;
soundEffectPromptAssist?: SoundEffectPromptAssistComposerController;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: (
@@ -775,15 +794,10 @@ function normalizeSoundDuration(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
return Math.min(10, Math.max(2, Math.round(value)));
}
function resetFailedAudioDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
return Math.min(
SOUND_EFFECT_DURATION_MAX_SECONDS,
Math.max(SOUND_EFFECT_DURATION_MIN_SECONDS, value),
);
}
const BACKGROUND_MUSIC_COMPOSER_LABEL = '生成游戏背景音乐';
@@ -796,22 +810,22 @@ const BACKGROUND_MUSIC_MODEL_LABEL = 'Suno';
function ImageCanvasAudioGenerationComposerView({
dialog,
style,
setGenerateDialog,
renderEditorPortal,
buildPortalMenuStyle,
promptAssist,
soundEffectPromptAssist,
updateCanvasGenerationDialogById,
onSubmit,
}: {
dialog: GenerateDialogState;
style: CSSProperties;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
renderEditorPortal: (node: ReactNode) => ReactNode;
buildPortalMenuStyle: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
promptAssist?: BackgroundMusicPromptAssistComposerController;
soundEffectPromptAssist?: SoundEffectPromptAssistComposerController;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: (
@@ -1083,23 +1097,67 @@ function ImageCanvasAudioGenerationComposerView({
);
}
const soundModel = dialog.soundModel ?? DEFAULT_SOUND_EFFECT_MODEL;
const soundEffectDialog = toSoundEffectGenerationDialog(dialog);
const controller = soundEffectPromptAssist;
if (!soundEffectDialog || !controller) {
return null;
}
const assistState = controller.getDialogState(soundEffectDialog.id);
const isAiProcessing = assistState.status === 'optimizing';
const isSubmitting = assistState.status === 'submitting';
const isLocked = isAiProcessing || isSubmitting || isGenerating;
const promptCodePointCount = countSoundEffectPromptCodePoints(
soundEffectDialog.prompt,
);
const isPromptOverLimit =
promptCodePointCount > SOUND_EFFECT_PROMPT_MAX_CODE_POINTS;
const canOptimize =
!isLocked && canOptimizeSoundEffectPrompt(soundEffectDialog.prompt);
const canGenerate =
!isLocked && canGenerateSoundEffectFromPrompt(soundEffectDialog.prompt);
const isUndoVisible =
assistState.undoPromptSnapshot !== null ||
assistState.temporaryPromptSnapshot !== null;
const canUndo = isUndoVisible && !isLocked;
const soundModel = DEFAULT_SOUND_EFFECT_MODEL;
const currentSoundModel =
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.find(
(option) => option.value === soundModel,
) ?? EDITOR_SOUND_EFFECT_MODEL_OPTIONS[0];
const soundDuration = normalizeSoundDuration(dialog.soundDurationSeconds);
const soundOptionLabel = `${soundDuration}`;
const soundDuration = normalizeSoundDuration(
soundEffectDialog.soundDurationSeconds,
);
const durationMode =
soundEffectDialog.soundDurationMode === 'auto' ? 'auto' : 'manual';
const isAutomaticDuration = durationMode === 'auto';
const soundLoop = soundEffectDialog.soundLoop === true;
const soundOptionLabel = isAutomaticDuration
? '自动时长'
: `${soundDuration}`;
const dialogLabel = '生成游戏音效';
const fixedModelLabel = currentSoundModel.label;
const fixedModelAriaLabel = `音效模型 ${fixedModelLabel}`;
const cost = calculateEditorSoundEffectPrice(soundModel);
const updateAudioDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === dialog.mode
const updateAudioDialog = (
patch: Partial<
Pick<
GenerateDialogState,
'prompt' | 'soundDurationMode' | 'soundDurationSeconds' | 'soundLoop'
>
>,
) => {
updateCanvasGenerationDialogById(soundEffectDialog.id, (currentDialog) =>
currentDialog.mode === 'audio-sound-effect'
? {
...resetFailedAudioDialogStatus(currentDialog),
...currentDialog,
status:
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
...patch,
}
: currentDialog,
@@ -1118,23 +1176,53 @@ function ImageCanvasAudioGenerationComposerView({
style={style}
role="dialog"
aria-label={dialogLabel}
aria-busy={isLocked}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (!isGenerating) {
onSubmit(dialog);
if (canGenerate) {
onSubmit(soundEffectDialog);
}
}}
>
<AutoGrowTextArea
aria-label="prompt"
value={dialog.prompt}
disabled={isGenerating}
aria-describedby={promptCounterId}
aria-invalid={isPromptOverLimit || undefined}
value={soundEffectDialog.prompt}
readOnly={isLocked}
placeholder="你希望生成什么音效?"
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
/>
{dialog.status === 'failed' ? (
<ImageCanvasSoundEffectPresetMarquee
presets={SOUND_EFFECT_PROMPT_PRESETS}
isLocked={isLocked}
onSelectPreset={(preset: SoundEffectPromptPreset) => {
const canonicalPrompt = controller.preparePreset(
soundEffectDialog.id,
);
if (canonicalPrompt === null) {
return;
}
updateAudioDialog({
prompt: appendSoundEffectPromptPreset(canonicalPrompt, preset),
});
}}
headerTrailing={
<span
id={promptCounterId}
className={
isPromptOverLimit
? 'image-canvas-editor__background-music-prompt-count image-canvas-editor__background-music-prompt-count--over-limit'
: 'image-canvas-editor__background-music-prompt-count'
}
>
{`${promptCodePointCount} / ${SOUND_EFFECT_PROMPT_MAX_CODE_POINTS}`}
</span>
}
/>
{assistState.errorMessage ? (
<PlatformStatusMessage
tone="error"
surface="platform"
@@ -1142,9 +1230,61 @@ function ImageCanvasAudioGenerationComposerView({
className="image-canvas-editor__generate-status"
role="alert"
>
{dialog.errorMessage}
{assistState.errorMessage}
</PlatformStatusMessage>
) : null}
{soundEffectDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{soundEffectDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__background-music-prompt-actions image-canvas-editor__sound-effect-prompt-actions">
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action image-canvas-editor__sound-effect-prompt-action--optimize"
aria-label="一键优化"
disabled={!canOptimize}
onClick={() => {
void controller.optimizePrompt(soundEffectDialog.id);
}}
>
<span
className="image-canvas-editor__background-music-prompt-action-icon"
aria-hidden="true"
>
<WandSparkles />
</span>
<span>{isAiProcessing ? '优化中' : '一键优化'}</span>
</PlatformActionButton>
{isUndoVisible ? (
<PlatformActionButton
type="button"
tone="ghost"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action image-canvas-editor__background-music-prompt-action--undo"
disabled={!canUndo}
onClick={() => controller.undoPrompt(soundEffectDialog.id)}
>
<span
className="image-canvas-editor__background-music-prompt-action-icon"
aria-hidden="true"
>
<Undo2 />
</span>
<span></span>
</PlatformActionButton>
) : null}
</div>
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
@@ -1152,7 +1292,7 @@ function ImageCanvasAudioGenerationComposerView({
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效时长 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
disabled={isGenerating}
disabled={isLocked}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
@@ -1174,15 +1314,31 @@ function ImageCanvasAudioGenerationComposerView({
<span className="image-canvas-editor__option-popover-title">
</span>
<label className="image-canvas-editor__sound-effect-auto-duration">
<input
type="checkbox"
aria-label="自动时长"
checked={isAutomaticDuration}
disabled={isLocked}
onChange={(event) =>
updateAudioDialog({
soundDurationMode: event.target.checked
? 'auto'
: 'manual',
})
}
/>
<span></span>
</label>
<div className="image-canvas-editor__video-duration-slider">
<input
type="range"
min={2}
max={10}
step={1}
min={SOUND_EFFECT_DURATION_MIN_SECONDS}
max={SOUND_EFFECT_DURATION_MAX_SECONDS}
step={0.1}
value={soundDuration}
disabled={isGenerating}
aria-label="音效时长"
disabled={isLocked || isAutomaticDuration}
aria-label="手动音效时长"
onChange={(event) =>
updateSoundDuration(Number(event.target.value))
}
@@ -1195,6 +1351,15 @@ function ImageCanvasAudioGenerationComposerView({
)
: null}
</div>
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__sound-effect-loop"
aria-label="Loop"
aria-pressed={soundLoop}
disabled={isLocked}
onClick={() => updateAudioDialog({ soundLoop: !soundLoop })}
>
Loop
</PlatformInlineOptionButton>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__readonly-generation-option">
<PlatformInlineOptionButton
ref={soundModelButtonRef}
@@ -1219,7 +1384,7 @@ function ImageCanvasAudioGenerationComposerView({
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={isGenerating}
disabled={!canGenerate}
aria-label={dialogLabel}
>
{isGenerating ? (
@@ -1261,6 +1426,7 @@ export function ImageCanvasGenerationComposerView({
isPickingUiDesignSpecFromCanvas,
generateDialog,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
updateCanvasGenerationDialogById,
generationComposerStyle,
iconComposerStyle,
@@ -1478,7 +1644,7 @@ export function ImageCanvasGenerationComposerView({
dialog={generateDialog}
style={generationComposerStyle}
promptAssist={backgroundMusicPromptAssist}
setGenerateDialog={setGenerateDialog}
soundEffectPromptAssist={soundEffectPromptAssist}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
updateCanvasGenerationDialogById={updateCanvasGenerationDialogById}
@@ -253,8 +253,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
prompt: '',
status: 'idle',
composerOpen: true,
soundModel: 'audio1.0',
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 5,
soundLoop: false,
placeholder: {
x: 270,
y: 300,
@@ -313,8 +315,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
mode: 'audio-sound-effect',
sourceLayerId: 'layer-sfx',
prompt: '金币收集音',
soundModel: 'audio1.0',
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 7,
soundLoop: false,
composerOpen: true,
placeholder: {
x: 472,
@@ -1,3 +1,7 @@
import {
SOUND_EFFECT_DURATION_MAX_SECONDS,
SOUND_EFFECT_DURATION_MIN_SECONDS,
} from '../../../packages/shared/src/contracts/editorAudio';
import { formatImageSizeValue } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
@@ -400,7 +404,9 @@ export function createSoundEffectGenerationDialogDraft({
status: 'idle',
composerOpen: true,
soundModel: DEFAULT_SOUND_EFFECT_MODEL,
soundDurationMode: 'manual',
soundDurationSeconds: DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
soundLoop: false,
placeholder: {
x: worldCenter.x - AUDIO_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - AUDIO_FRAME_DISPLAY_SIZE.height / 2,
@@ -521,13 +527,10 @@ function resolveAudioRedrawMode(sourceLayer: CanvasLayer) {
}
function resolveAudioRedrawSoundModel(
sourceLayer: CanvasLayer,
_sourceLayer: CanvasLayer,
): NonNullable<GenerateDialogState['soundModel']> {
const fieldValue = findGenerationInputFieldValue(sourceLayer, [
'model',
])?.trim();
// 中文注释:编辑器音效改造入口当前只暴露 Vidu audio1.0,历史快照里的其他模型统一回落到默认模型。
return fieldValue === DEFAULT_SOUND_EFFECT_MODEL ? 'audio1.0' : 'audio1.0';
// 历史 Vidu 素材只提供用户 Prompt 与可兼容的手动时长;重绘始终打开 SFX V2。
return DEFAULT_SOUND_EFFECT_MODEL;
}
function resolveAudioRedrawSoundDuration(sourceLayer: CanvasLayer) {
@@ -535,12 +538,15 @@ function resolveAudioRedrawSoundDuration(sourceLayer: CanvasLayer) {
'时长',
'duration',
]);
const matchedValue = fieldValue?.match(/\d+/u)?.[0];
const duration = matchedValue ? Number.parseInt(matchedValue, 10) : null;
const matchedValue = fieldValue?.match(/\d+(?:\.\d+)?/u)?.[0];
const duration = matchedValue ? Number.parseFloat(matchedValue) : null;
if (!duration || !Number.isFinite(duration)) {
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
return Math.min(10, Math.max(2, duration));
return Math.min(
SOUND_EFFECT_DURATION_MAX_SECONDS,
Math.max(SOUND_EFFECT_DURATION_MIN_SECONDS, duration),
);
}
export function createAudioRedrawGenerationDialogDraft(
@@ -576,7 +582,9 @@ export function createAudioRedrawGenerationDialogDraft(
return {
...baseDraft,
soundModel: resolveAudioRedrawSoundModel(sourceLayer),
soundDurationMode: 'manual',
soundDurationSeconds: resolveAudioRedrawSoundDuration(sourceLayer),
soundLoop: false,
};
}
@@ -1033,8 +1041,11 @@ export function createSameSourceGenerationDialogDraft({
...draft,
prompt,
soundModel: sourceDialog?.soundModel ?? draft.soundModel,
soundDurationMode:
sourceDialog?.soundDurationMode ?? draft.soundDurationMode,
soundDurationSeconds:
sourceDialog?.soundDurationSeconds ?? draft.soundDurationSeconds,
soundLoop: sourceDialog?.soundLoop ?? draft.soundLoop,
makeInstrumental:
sourceDialog?.makeInstrumental ?? draft.makeInstrumental,
};
@@ -1,3 +1,4 @@
import { EDITOR_SOUND_EFFECT_MODEL } from '../../../packages/shared/src/contracts/editorAudio';
import { ApiClientError } from '../../services/apiClient';
import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient';
import type {
@@ -316,7 +317,7 @@ export const VIDEO_MODEL_VEO_3_1_FAST = 'veo3.1-fast';
export const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST;
export const CHARACTER_ANIMATION_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST;
export const SOUND_EFFECT_MODEL_VIDU = 'audio1.0';
export const DEFAULT_SOUND_EFFECT_MODEL = SOUND_EFFECT_MODEL_VIDU;
export const DEFAULT_SOUND_EFFECT_MODEL = EDITOR_SOUND_EFFECT_MODEL;
export const DEFAULT_SOUND_EFFECT_DURATION_SECONDS = 5;
export const SOUND_EFFECT_DURATION_OPTIONS = Array.from(
{ length: 9 },
@@ -354,6 +355,7 @@ export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = {
[VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
} as const;
export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = {
[EDITOR_SOUND_EFFECT_MODEL]: 5,
[SOUND_EFFECT_MODEL_VIDU]: 5,
} as const;
export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = {
@@ -396,6 +398,11 @@ export const EDITOR_MODEL_MUD_POINT_CONFIG = {
unit: 'perGeneration',
price: EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[SOUND_EFFECT_MODEL_VIDU],
},
[EDITOR_SOUND_EFFECT_MODEL]: {
unit: 'perGeneration',
price:
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[EDITOR_SOUND_EFFECT_MODEL],
},
[BACKGROUND_MUSIC_MODEL_SUNO]: {
unit: 'perGeneration',
price:
@@ -408,7 +415,7 @@ let runtimeEditorGenerationPricingConfig: EditorGenerationPricingConfig = {
models: cloneModelPricing(EDITOR_MODEL_MUD_POINT_CONFIG),
};
export const EDITOR_SOUND_EFFECT_MODEL_OPTIONS = [
{ label: 'Vidu', value: SOUND_EFFECT_MODEL_VIDU },
{ label: 'ElevenLabs', value: EDITOR_SOUND_EFFECT_MODEL },
] as const;
export const SEEDANCE_VIDEO_REFERENCE_LIMITS = {
image: 9,
@@ -958,7 +965,9 @@ export function calculateEditorSoundEffectPrice(
model: string | null | undefined,
) {
const normalizedModel = (model ?? DEFAULT_SOUND_EFFECT_MODEL).trim();
return readModelFlatPrice(normalizedModel, DEFAULT_SOUND_EFFECT_MODEL);
// T4 只切前端固定模型展示;新模型的动态定价键由 T5 同批接入。中间态继续以
// 历史 Vidu 的 5 泥点前端兜底展示,服务端价格始终是正式真相。
return readModelFlatPrice(normalizedModel, SOUND_EFFECT_MODEL_VIDU);
}
export function calculateEditorBackgroundMusicPrice(
@@ -0,0 +1,76 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasSoundEffectPresetMarquee } from './ImageCanvasSoundEffectPresetMarquee';
import { SOUND_EFFECT_PROMPT_PRESETS } from './ImageCanvasSoundEffectPresetModel';
describe('ImageCanvasSoundEffectPresetMarquee', () => {
beforeEach(() => {
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1),
);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
it('renders one accessible control for each of the 40 + 12 frozen presets', () => {
const onSelectPreset = vi.fn();
render(
<ImageCanvasSoundEffectPresetMarquee
presets={SOUND_EFFECT_PROMPT_PRESETS}
isLocked={false}
onSelectPreset={onSelectPreset}
headerTrailing={<span>0 / 2048</span>}
/>,
);
expect(screen.getByText('0 / 2048')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '展开' }));
for (const preset of SOUND_EFFECT_PROMPT_PRESETS) {
const button = screen.getByRole('button', { name: preset.label });
expect(button.className).toContain(
`image-canvas-editor__sound-effect-preset--${preset.group}`,
);
}
expect(SOUND_EFFECT_PROMPT_PRESETS).toHaveLength(52);
fireEvent.click(
screen.getByRole('button', {
name: SOUND_EFFECT_PROMPT_PRESETS[0].label,
}),
);
expect(onSelectPreset).toHaveBeenCalledWith(SOUND_EFFECT_PROMPT_PRESETS[0]);
});
it('keeps expansion and preset controls truly disabled while locked', () => {
const view = render(
<ImageCanvasSoundEffectPresetMarquee
presets={SOUND_EFFECT_PROMPT_PRESETS}
isLocked={false}
onSelectPreset={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '展开' }));
view.rerender(
<ImageCanvasSoundEffectPresetMarquee
presets={SOUND_EFFECT_PROMPT_PRESETS}
isLocked
onSelectPreset={vi.fn()}
/>,
);
expect(
(screen.getByRole('button', { name: '收起' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: SOUND_EFFECT_PROMPT_PRESETS[0].label,
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
});
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import { ImageCanvasAudioPresetMarquee } from './ImageCanvasAudioPresetMarquee';
import type { SoundEffectPromptPreset } from './ImageCanvasSoundEffectPresetModel';
export function ImageCanvasSoundEffectPresetMarquee({
presets,
isLocked,
onSelectPreset,
headerTrailing,
}: {
presets: readonly SoundEffectPromptPreset[];
isLocked: boolean;
onSelectPreset: (preset: SoundEffectPromptPreset) => void;
headerTrailing?: ReactNode;
}) {
return (
<ImageCanvasAudioPresetMarquee
presets={presets}
isLocked={isLocked}
onSelectPreset={onSelectPreset}
headerTrailing={headerTrailing}
getPresetClassName={(preset) =>
`image-canvas-editor__background-music-preset image-canvas-editor__sound-effect-preset--${preset.group}`
}
/>
);
}
@@ -4,6 +4,7 @@ import soundEffectPromptCanonicalizationCases from '../../../packages/shared/tes
import {
canonicalizeSoundEffectPrompt,
countSoundEffectPromptCodePoints,
createSoundEffectPromptStateModel,
SOUND_EFFECT_PROMPT_MAX_CODE_POINTS,
validateSoundEffectPrompt,
} from './ImageCanvasSoundEffectPromptModel';
@@ -67,4 +68,94 @@ describe('ImageCanvasSoundEffectPromptModel', () => {
});
}
});
it('creates one dialog-scoped optimization operation and swaps one undo snapshot', () => {
const model = createSoundEffectPromptStateModel();
const started = model.beginOperation({
dialogId: 'dialog-a',
status: 'optimizing',
prompt: '\u0085金币落地声\u3000',
});
expect(started.started).toBe(true);
expect(started.canonicalPrompt).toBe('金币落地声');
expect(started.state).toMatchObject({
status: 'optimizing',
undoPromptSnapshot: null,
temporaryPromptSnapshot: '金币落地声',
});
expect(
model.beginOperation({
dialogId: 'dialog-a',
status: 'optimizing',
prompt: '另一个输入',
}).reason,
).toBe('duplicate');
const resolved = model.resolveAiOperation(
started.operation!,
'清脆明亮的金币落地声',
);
expect(resolved).toMatchObject({
applied: true,
prompt: '清脆明亮的金币落地声',
state: {
status: 'idle',
undoPromptSnapshot: '金币落地声',
temporaryPromptSnapshot: null,
},
});
expect(
model.swapUndoSnapshot('dialog-a', '清脆明亮的金币落地声'),
).toMatchObject({
applied: true,
prompt: '金币落地声',
state: { undoPromptSnapshot: '清脆明亮的金币落地声' },
});
});
it('clears the replaced snapshot when a later optimization fails', () => {
const model = createSoundEffectPromptStateModel();
const first = model.beginOperation({
dialogId: 'dialog-a',
status: 'optimizing',
prompt: '原始描述',
});
model.resolveAiOperation(first.operation!, '第一次优化');
const second = model.beginOperation({
dialogId: 'dialog-a',
status: 'optimizing',
prompt: '手动修改后的描述',
});
expect(second.state.undoPromptSnapshot).toBeNull();
expect(model.rejectOperation(second.operation!)).toMatchObject({
applied: true,
prompt: '手动修改后的描述',
state: {
status: 'idle',
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
},
});
});
it('keeps submission and preset state isolated by dialog ID', () => {
const model = createSoundEffectPromptStateModel();
const submission = model.beginOperation({
dialogId: 'dialog-a',
status: 'submitting',
prompt: ' 提交描述 ',
});
expect(submission.started).toBe(true);
expect(model.preparePreset('dialog-a', '不应写入').applied).toBe(false);
expect(model.preparePreset('dialog-b', ' 另一面板 ').prompt).toBe(
'另一面板',
);
expect(
model.completeSubmittingOperation(submission.operation!),
).toMatchObject({
applied: true,
prompt: '提交描述',
state: { status: 'idle' },
});
});
});
@@ -1,4 +1,10 @@
import type {
CanvasGenerationDialogState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
export const SOUND_EFFECT_PROMPT_MAX_CODE_POINTS = 2048;
const SOUND_EFFECT_PROMPT_OPERATION_ID_PREFIX = 'sound-effect-prompt-operation';
const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u;
const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u;
@@ -16,6 +22,40 @@ export type SoundEffectPromptValidationResult =
reason: 'empty' | 'too-long';
};
export type SoundEffectPromptOperationStatus = 'optimizing' | 'submitting';
export type SoundEffectPromptDialogState = {
dialogId: string;
status: 'idle' | SoundEffectPromptOperationStatus;
operationId: string | null;
undoPromptSnapshot: string | null;
temporaryPromptSnapshot: string | null;
};
export type SoundEffectPromptOperation = {
dialogId: string;
operationId: string;
status: SoundEffectPromptOperationStatus;
canonicalPrompt: string;
};
type InternalSoundEffectPromptDialogState = SoundEffectPromptDialogState & {
activeCanonicalPrompt: string | null;
};
export type SoundEffectGenerationDialogState = CanvasGenerationDialogState & {
mode: 'audio-sound-effect';
};
export function toSoundEffectGenerationDialog(
dialog: GenerateDialogState | null,
): SoundEffectGenerationDialogState | null {
if (!dialog || dialog.mode !== 'audio-sound-effect' || !dialog.id) {
return null;
}
return { ...dialog, id: dialog.id, mode: 'audio-sound-effect' };
}
/**
* 只删除首尾 Unicode `White_Space`。内部空白、组合字符、ZWJ、U+200B、U+FEFF
* 及其它 code point 均原样保留;不能替换为语义不同的原生 `trim()`。
@@ -53,3 +93,297 @@ export function validateSoundEffectPrompt(
}
return { ok: true, prompt: canonicalPrompt, charCount };
}
export function canOptimizeSoundEffectPrompt(prompt: string) {
return validateSoundEffectPrompt(prompt).ok;
}
export function canGenerateSoundEffectFromPrompt(prompt: string) {
return validateSoundEffectPrompt(prompt).ok;
}
function createIdleSoundEffectPromptDialogState(
dialogId: string,
): InternalSoundEffectPromptDialogState {
return {
dialogId,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
}
function toPublicSoundEffectPromptDialogState(
state: InternalSoundEffectPromptDialogState,
): SoundEffectPromptDialogState {
return {
dialogId: state.dialogId,
status: state.status,
operationId: state.operationId,
undoPromptSnapshot: state.undoPromptSnapshot,
temporaryPromptSnapshot: state.temporaryPromptSnapshot,
};
}
/**
* SFX Prompt 的纯状态机。operation 在调用方第一次 await 前同步取得;模型只保存
* dialog 级临时状态,不把快照、错误或预设 UI 状态写入画布 layout。
*/
export function createSoundEffectPromptStateModel() {
const dialogStates = new Map<string, InternalSoundEffectPromptDialogState>();
let operationSequence = 0;
const readInternalState = (dialogId: string) =>
dialogStates.get(dialogId) ??
createIdleSoundEffectPromptDialogState(dialogId);
const saveState = (state: InternalSoundEffectPromptDialogState) => {
dialogStates.set(state.dialogId, state);
return toPublicSoundEffectPromptDialogState(state);
};
const getDialogState = (dialogId: string) =>
toPublicSoundEffectPromptDialogState(readInternalState(dialogId));
const beginOperation = ({
dialogId,
status,
prompt,
}: {
dialogId: string;
status: SoundEffectPromptOperationStatus;
prompt: string;
}) => {
const currentState = readInternalState(dialogId);
const validation = validateSoundEffectPrompt(prompt);
const canonicalPrompt = validation.prompt;
if (currentState.status === status && currentState.operationId !== null) {
return {
started: false as const,
reason: 'duplicate' as const,
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
operation: null,
state: toPublicSoundEffectPromptDialogState(currentState),
};
}
if (currentState.status !== 'idle') {
return {
started: false as const,
reason: 'locked' as const,
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
operation: null,
state: toPublicSoundEffectPromptDialogState(currentState),
};
}
if (!validation.ok) {
return {
started: false as const,
reason: 'ineligible' as const,
canonicalPrompt,
operation: null,
state: toPublicSoundEffectPromptDialogState(currentState),
};
}
operationSequence += 1;
const operation: SoundEffectPromptOperation = {
dialogId,
operationId: `${SOUND_EFFECT_PROMPT_OPERATION_ID_PREFIX}-${operationSequence}`,
status,
canonicalPrompt,
};
const isAiOperation = status === 'optimizing';
const nextState: InternalSoundEffectPromptDialogState = {
...currentState,
status,
operationId: operation.operationId,
undoPromptSnapshot: isAiOperation
? null
: currentState.undoPromptSnapshot,
temporaryPromptSnapshot: isAiOperation ? canonicalPrompt : null,
activeCanonicalPrompt: canonicalPrompt,
};
return {
started: true as const,
reason: 'started' as const,
canonicalPrompt,
operation,
state: saveState(nextState),
};
};
const isCurrentOperation = (
operation: SoundEffectPromptOperation,
state: InternalSoundEffectPromptDialogState,
) =>
state.dialogId === operation.dialogId &&
state.operationId === operation.operationId &&
state.status === operation.status;
const resolveAiOperation = (
operation: SoundEffectPromptOperation,
prompt: string,
) => {
const currentState = readInternalState(operation.dialogId);
if (
operation.status !== 'optimizing' ||
!isCurrentOperation(operation, currentState)
) {
return {
applied: false,
prompt: null,
state: toPublicSoundEffectPromptDialogState(currentState),
};
}
const validation = validateSoundEffectPrompt(prompt);
if (!validation.ok) {
const failedState: InternalSoundEffectPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: false,
prompt: null,
state: saveState(failedState),
};
}
const completedState: InternalSoundEffectPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: currentState.temporaryPromptSnapshot,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: validation.prompt,
state: saveState(completedState),
};
};
const rejectOperation = (operation: SoundEffectPromptOperation) => {
const currentState = readInternalState(operation.dialogId);
if (!isCurrentOperation(operation, currentState)) {
return {
applied: false,
prompt: null,
state: toPublicSoundEffectPromptDialogState(currentState),
};
}
const isAiOperation = operation.status === 'optimizing';
const failedState: InternalSoundEffectPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: isAiOperation
? null
: currentState.undoPromptSnapshot,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: currentState.activeCanonicalPrompt,
state: saveState(failedState),
};
};
const completeSubmittingOperation = (
operation: SoundEffectPromptOperation,
) => {
const currentState = readInternalState(operation.dialogId);
if (
operation.status !== 'submitting' ||
!isCurrentOperation(operation, currentState)
) {
return {
applied: false,
prompt: null,
state: toPublicSoundEffectPromptDialogState(currentState),
};
}
const completedState: InternalSoundEffectPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: currentState.activeCanonicalPrompt ?? operation.canonicalPrompt,
state: saveState(completedState),
};
};
const preparePreset = (dialogId: string, prompt: string) => {
const currentState = readInternalState(dialogId);
const canonicalPrompt = canonicalizeSoundEffectPrompt(prompt);
if (currentState.status !== 'idle') {
return {
applied: false,
prompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
state: toPublicSoundEffectPromptDialogState(currentState),
};
}
const nextState: InternalSoundEffectPromptDialogState = {
...currentState,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: canonicalPrompt,
state: saveState(nextState),
};
};
const swapUndoSnapshot = (dialogId: string, prompt: string) => {
const currentState = readInternalState(dialogId);
if (
currentState.status !== 'idle' ||
currentState.undoPromptSnapshot === null
) {
return {
applied: false,
prompt,
state: toPublicSoundEffectPromptDialogState(currentState),
};
}
const canonicalPrompt = canonicalizeSoundEffectPrompt(prompt);
const nextPrompt = currentState.undoPromptSnapshot;
const nextState: InternalSoundEffectPromptDialogState = {
...currentState,
undoPromptSnapshot: canonicalPrompt,
};
return {
applied: true,
prompt: nextPrompt,
state: saveState(nextState),
};
};
const closeDialog = (dialogId: string) => dialogStates.delete(dialogId);
const reset = () => dialogStates.clear();
return {
getDialogState,
beginOperation,
resolveAiOperation,
rejectOperation,
completeSubmittingOperation,
preparePreset,
swapUndoSnapshot,
closeDialog,
reset,
};
}
@@ -31,6 +31,7 @@ import {
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
import { useImageCanvasSoundEffectPromptAssist } from './useImageCanvasSoundEffectPromptAssist';
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
@@ -266,15 +267,23 @@ function SubmissionWorkflowHarness({
);
const layerCounterRef = useRef(0);
const dialogs = useCanvasGenerationDialogs();
const backgroundMusicPromptAssist =
useImageCanvasBackgroundMusicPromptAssist({
const backgroundMusicPromptAssist = useImageCanvasBackgroundMusicPromptAssist(
{
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
updateCanvasGenerationDialogById:
dialogs.updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
},
);
const soundEffectPromptAssist = useImageCanvasSoundEffectPromptAssist({
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
const sourceLayer = layers[0] ?? null;
const quickEditSourceLayer = quickEditPanel
@@ -313,6 +322,7 @@ function SubmissionWorkflowHarness({
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
activeCanvasGenerationDialogId: activeCanvasDialog?.id,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) => {
onAppendCanvasLayers?.(nextLayers);
@@ -383,6 +393,11 @@ function SubmissionWorkflowHarness({
? backgroundMusicPromptAssist.getDialogState(trackedDialogId).status
: '-'}
</span>
<span data-testid="sound-effect-assist">
{trackedDialogId
? soundEffectPromptAssist.getDialogState(trackedDialogId).status
: '-'}
</span>
<span data-testid="quick-edit">
{quickEditPanel
? `${quickEditPanel.sourceLayerId}:${quickEditPanel.status}:${quickEditPanel.prompt || '-'}:${quickEditPanel.errorMessage ?? '-'}`
@@ -1980,6 +1995,67 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('claims and canonicalizes one SFX submission before the first await', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
generateEditorSoundEffectMock.mockReturnValueOnce(generation.promise);
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="editor-project-sfx"
initialDialog={{
id: 'dialog-sfx-claim',
mode: 'audio-sound-effect',
prompt: '\u0085循环环境声\u3000',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'auto',
soundDurationSeconds: 7.3,
soundLoop: true,
placeholder: {
x: 200,
y: 160,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(generateEditorSoundEffectMock).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-sound-effect:循环环境声:idle:open:-',
);
expect(screen.getByTestId('sound-effect-assist').textContent).toBe(
'submitting',
);
await act(async () => {
generation.resolve(
createAudioGenerated({
prompt: '循环环境声',
audioKind: 'sound-effect',
}),
);
await generation.promise;
});
await waitFor(() => {
expect(screen.getByTestId('sound-effect-assist').textContent).toBe(
'idle',
);
expect(screen.getByTestId('layers').textContent).toContain(
'sound-effect',
);
});
});
it('claims and canonicalizes one background music submission before the first await', async () => {
const representativeComplexPromptCase =
backgroundMusicPromptCanonicalizationCases.find(
@@ -91,6 +91,7 @@ import type {
import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel';
import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel';
import type { BackgroundMusicPromptAssistController } from './useImageCanvasBackgroundMusicPromptAssist';
import type { SoundEffectPromptAssistController } from './useImageCanvasSoundEffectPromptAssist';
type CanvasSize = { width: number; height: number };
@@ -330,6 +331,7 @@ type GenerationSubmissionWorkflowOptions = {
) => CanvasGenerationDialogState | undefined;
activeCanvasGenerationDialogId?: string | null;
backgroundMusicPromptAssist: BackgroundMusicPromptAssistController;
soundEffectPromptAssist: SoundEffectPromptAssistController;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
@@ -642,6 +644,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
getCanvasGenerationDialogById,
activeCanvasGenerationDialogId,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
@@ -704,6 +707,26 @@ export function useImageCanvasGenerationSubmissionWorkflow({
[getCanvasGenerationDialogById],
);
const isSoundEffectSubmissionUiTargetCurrent = useCallback(
(scope: {
currentUserId: string | null;
projectId: string | null;
dialogId: string;
scopeVersion: number;
}) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(scope.dialogId);
return (
currentScope.mounted &&
currentScope.version === scope.scopeVersion &&
Object.is(scope.currentUserId, currentScope.currentUserId) &&
Object.is(scope.projectId, currentScope.projectId) &&
currentDialog?.mode === 'audio-sound-effect'
);
},
[getCanvasGenerationDialogById],
);
const isBackgroundMusicSubmissionAccountCurrent = useCallback(
(scope: { currentUserId: string | null }) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
@@ -1862,15 +1885,45 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
const backgroundMusicDialog =
canvasDialog?.mode === 'audio-background-music' ? canvasDialog : null;
const soundEffectDialog =
canvasDialog?.mode === 'audio-sound-effect' ? canvasDialog : null;
if (dialog.mode === 'audio-background-music' && !backgroundMusicDialog) {
return;
}
if (dialog.mode === 'audio-sound-effect' && !soundEffectDialog) {
return;
}
const backgroundMusicClaim = backgroundMusicDialog
? backgroundMusicPromptAssist.beginSubmission(backgroundMusicDialog.id)
: null;
if (backgroundMusicDialog && !backgroundMusicClaim) {
return;
}
const soundEffectClaim = soundEffectDialog
? soundEffectPromptAssist.beginSubmission(soundEffectDialog.id)
: null;
if (soundEffectDialog && !soundEffectClaim) {
const validation = validateSoundEffectPrompt(soundEffectDialog.prompt);
if (!validation.ok) {
updateCanvasGenerationDialogById(
soundEffectDialog.id,
(currentDialog) =>
currentDialog.mode === 'audio-sound-effect'
? {
...currentDialog,
prompt: validation.prompt,
status: 'failed',
composerOpen: true,
errorMessage:
validation.reason === 'empty'
? '音效描述不能为空'
: '音效描述不能超过 2048 个字符',
}
: currentDialog,
);
}
return;
}
const backgroundMusicSubmission =
backgroundMusicDialog && backgroundMusicClaim
? {
@@ -1883,23 +1936,34 @@ export function useImageCanvasGenerationSubmissionWorkflow({
currentBackgroundMusicSubmissionScopeRef.current.version,
}
: null;
const soundEffectPrompt =
dialog.mode === 'audio-sound-effect'
? validateSoundEffectPrompt(dialog.prompt)
const soundEffectSubmission =
soundEffectDialog && soundEffectClaim
? {
currentUserId: currentUserId ?? null,
projectId: projectId ?? null,
dialogId: soundEffectDialog.id,
...soundEffectClaim,
scopeVersion:
currentBackgroundMusicSubmissionScopeRef.current.version,
}
: null;
const normalizedPrompt =
backgroundMusicSubmission?.prompt ??
soundEffectPrompt?.prompt ??
soundEffectSubmission?.prompt ??
(dialog.prompt.trim() ||
(dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片'));
if (!backgroundMusicSubmission && canvasDialog) {
if (
!backgroundMusicSubmission &&
!soundEffectSubmission &&
canvasDialog
) {
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
...currentDialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: false,
}));
} else if (!backgroundMusicSubmission) {
} else if (!backgroundMusicSubmission && !soundEffectSubmission) {
setGenerateDialog({
...dialog,
prompt: normalizedPrompt,
@@ -1911,9 +1975,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
let backgroundMusicSubmissionAccepted = false;
let backgroundMusicSubmissionUiOwned = false;
let backgroundMusicQueueAcceptedWhileCurrentAndOpen = false;
let soundEffectSubmissionAccepted = false;
let soundEffectSubmissionUiOwned = false;
let soundEffectQueueAcceptedWhileCurrentAndOpen = false;
try {
const dialogForSubmission = soundEffectSubmission
? {
...dialog,
prompt: soundEffectSubmission.prompt,
soundDurationMode: soundEffectSubmission.durationMode,
soundDurationSeconds: soundEffectSubmission.manualDurationSeconds,
soundLoop: soundEffectSubmission.loop,
}
: dialog;
const submissionPlan = buildImageGenerationSubmissionPlan({
dialog,
dialog: dialogForSubmission,
layers,
nextGeneratedIndex: layerCounterRef.current + 1,
canonicalBackgroundMusicPrompt: backgroundMusicSubmission?.prompt,
@@ -2145,9 +2221,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
});
const isBackgroundMusic =
submissionPlan.audioKind === 'background-music';
const isSoundEffect = submissionPlan.audioKind === 'sound-effect';
const backgroundMusicQueueState = isBackgroundMusic
? queuedStateFromResponse(generated)
: null;
const soundEffectQueueState = isSoundEffect
? queuedStateFromResponse(generated)
: null;
if (isBackgroundMusic && backgroundMusicSubmission) {
backgroundMusicSubmissionAccepted = true;
const currentScope =
@@ -2192,7 +2272,50 @@ export function useImageCanvasGenerationSubmissionWorkflow({
backgroundMusicSubmission,
)
) {
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
notifyWalletBalanceMayHaveChanged(
onWalletBalanceMayHaveChanged,
);
}
}
if (isSoundEffect && soundEffectSubmission) {
soundEffectSubmissionAccepted = true;
const currentScope =
currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(
soundEffectSubmission.dialogId,
);
soundEffectQueueAcceptedWhileCurrentAndOpen =
Boolean(soundEffectQueueState) &&
currentScope.activeCanvasGenerationDialogId ===
soundEffectSubmission.dialogId &&
currentDialog?.composerOpen !== false;
soundEffectSubmissionUiOwned =
soundEffectPromptAssist.finishSubmission({
operation: soundEffectSubmission.operation,
accepted: true,
});
if (
soundEffectQueueState &&
soundEffectSubmissionUiOwned &&
isSoundEffectSubmissionUiTargetCurrent(soundEffectSubmission)
) {
updateCanvasGenerationDialogById(
soundEffectSubmission.dialogId,
(currentDialog) =>
currentDialog.mode === 'audio-sound-effect'
? {
...currentDialog,
prompt: soundEffectSubmission.prompt,
soundDurationMode: soundEffectSubmission.durationMode,
soundDurationSeconds:
soundEffectSubmission.manualDurationSeconds,
soundLoop: soundEffectSubmission.loop,
status: 'generating',
composerOpen: false,
errorMessage: undefined,
}
: currentDialog,
);
}
}
if (
@@ -2210,7 +2333,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot(projectSnapshot);
}
}
: applyProjectSnapshot,
: soundEffectSubmission && applyProjectSnapshot
? (projectSnapshot) => {
if (
soundEffectSubmissionUiOwned &&
isSoundEffectSubmissionUiTargetCurrent(
soundEffectSubmission,
)
) {
applyProjectSnapshot(projectSnapshot);
}
}
: applyProjectSnapshot,
backgroundMusicSubmission && onQueuedGenerationTask
? () => {
if (
@@ -2221,7 +2355,17 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onQueuedGenerationTask();
}
}
: onQueuedGenerationTask,
: soundEffectSubmission && onQueuedGenerationTask
? () => {
if (
isBackgroundMusicSubmissionProjectCurrent(
soundEffectSubmission,
)
) {
onQueuedGenerationTask();
}
}
: onQueuedGenerationTask,
backgroundMusicSubmission && onWalletBalanceMayHaveChanged
? () => {
if (
@@ -2232,7 +2376,17 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onWalletBalanceMayHaveChanged();
}
}
: onWalletBalanceMayHaveChanged,
: soundEffectSubmission && onWalletBalanceMayHaveChanged
? () => {
if (
isBackgroundMusicSubmissionAccountCurrent(
soundEffectSubmission,
)
) {
onWalletBalanceMayHaveChanged();
}
}
: onWalletBalanceMayHaveChanged,
)
) {
return;
@@ -2246,6 +2400,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
) {
return;
}
if (
soundEffectSubmission &&
(!soundEffectSubmissionUiOwned ||
!isSoundEffectSubmissionUiTargetCurrent(soundEffectSubmission))
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
@@ -2354,9 +2515,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
backgroundMusicSubmission,
)
) {
notifyWalletBalanceMayHaveChanged(
onWalletBalanceMayHaveChanged,
);
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
}
}
if (
@@ -2391,6 +2550,48 @@ export function useImageCanvasGenerationSubmissionWorkflow({
: latestDialog,
);
}
} else if (soundEffectSubmission) {
if (!soundEffectSubmissionAccepted) {
soundEffectSubmissionUiOwned =
soundEffectPromptAssist.finishSubmission({
operation: soundEffectSubmission.operation,
accepted: false,
});
}
if (
soundEffectSubmissionUiOwned &&
isSoundEffectSubmissionUiTargetCurrent(soundEffectSubmission)
) {
const currentScope =
currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(
soundEffectSubmission.dialogId,
);
const shouldReopenComposer =
currentScope.activeCanvasGenerationDialogId ===
soundEffectSubmission.dialogId &&
(currentDialog?.composerOpen !== false ||
soundEffectQueueAcceptedWhileCurrentAndOpen);
updateCanvasGenerationDialogById(
soundEffectSubmission.dialogId,
(latestDialog) =>
latestDialog.mode === 'audio-sound-effect'
? {
...latestDialog,
prompt: soundEffectSubmission.prompt,
soundDurationMode: soundEffectSubmission.durationMode,
soundDurationSeconds:
soundEffectSubmission.manualDurationSeconds,
soundLoop: soundEffectSubmission.loop,
status: 'failed',
composerOpen: shouldReopenComposer
? true
: latestDialog.composerOpen,
errorMessage: resolveImageGenerationErrorMessage(error),
}
: latestDialog,
);
}
} else if (canvasDialog) {
updateCanvasGenerationDialogById(canvasDialog.id, () => ({
...canvasDialog,
@@ -2422,6 +2623,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
isBackgroundMusicSubmissionAccountCurrent,
isBackgroundMusicSubmissionProjectCurrent,
isBackgroundMusicSubmissionUiTargetCurrent,
isSoundEffectSubmissionUiTargetCurrent,
layerCounterRef,
layers,
quickEditSelectionState,
@@ -2430,6 +2632,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
assetFolderId,
applyProjectSnapshot,
setGenerateDialog,
soundEffectPromptAssist,
updateCanvasGenerationDialogById,
upsertGeneratedAsset,
onGenerationWarning,
@@ -438,6 +438,7 @@ export function useImageCanvasGenerationSurface({
backgroundMusicPromptAssist={
generationWorkflow.backgroundMusicPromptAssist
}
soundEffectPromptAssist={generationWorkflow.soundEffectPromptAssist}
updateCanvasGenerationDialogById={updateCanvasGenerationDialogById}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
generationComposerStyle={generationComposerStyle}
@@ -1671,12 +1671,12 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '金币跳出音',
model: 'audio1.0',
model: 'eleven_text_to_sound_v2',
duration: 8,
generationInputs: {
fields: [
{ title: 'prompt', value: '金币跳出音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'model', value: 'eleven_text_to_sound_v2' },
{ title: '时长', value: '8秒' },
],
references: [],
@@ -133,6 +133,7 @@ import {
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
import { useImageCanvasSoundEffectPromptAssist } from './useImageCanvasSoundEffectPromptAssist';
import { useInlineGenerationPlaceholderOwnership } from './useInlineGenerationPlaceholderExpiry';
type CanvasSize = { width: number; height: number };
@@ -1024,6 +1025,13 @@ export function useImageCanvasGenerationWorkflow({
currentUserId,
projectId,
});
const soundEffectPromptAssist = useImageCanvasSoundEffectPromptAssist({
canvasGenerationDialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
const [
@@ -3635,6 +3643,7 @@ export function useImageCanvasGenerationWorkflow({
? generateDialog.id
: null,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
@@ -4245,6 +4254,7 @@ export function useImageCanvasGenerationWorkflow({
quickEditSelectionState,
quickEditSelectionSourceLayer,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
appendUiAssetExtractionReferences,
@@ -4351,6 +4361,7 @@ export function useImageCanvasGenerationWorkflow({
[
effectiveCharacterAnimationPanel,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
characterAnimationPrice,
characterAnimationSourceLayer,
clearDeletedLayerGenerationState,
@@ -0,0 +1,392 @@
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { type SetStateAction, useCallback, useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
import { useImageCanvasSoundEffectPromptAssist } from './useImageCanvasSoundEffectPromptAssist';
const clientMocks = vi.hoisted(() => ({
optimize: vi.fn(),
}));
vi.mock('../../services/image-editor/editorProjectClient', () => ({
optimizeEditorSoundEffectPrompt: clientMocks.optimize,
}));
type OptimizeResponse = { prompt: string; charCount: number };
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function createDialog(
id: string,
prompt: string,
overrides: Partial<CanvasGenerationDialogState> = {},
): CanvasGenerationDialogState {
return {
id,
mode: 'audio-sound-effect',
prompt,
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 5,
soundLoop: false,
...overrides,
};
}
function requireDialog(
dialogs: CanvasGenerationDialogState[],
dialogId: string,
) {
const dialog = dialogs.find((candidate) => candidate.id === dialogId);
if (!dialog) {
throw new Error(`missing test dialog: ${dialogId}`);
}
return dialog;
}
function useHarness({
initialDialogs,
currentUserId,
projectId,
}: {
initialDialogs: CanvasGenerationDialogState[];
currentUserId?: string | null;
projectId?: string | null;
}) {
const [dialogs, setDialogs] = useState(initialDialogs);
const dialogsRef = useRef(dialogs);
dialogsRef.current = dialogs;
const getCanvasGenerationDialogById = useCallback(
(dialogId: string) =>
dialogsRef.current.find((dialog) => dialog.id === dialogId),
[],
);
const updateCanvasGenerationDialogById = useCallback(
(
dialogId: string,
updater: (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
) => {
const currentDialog = dialogsRef.current.find(
(dialog) => dialog.id === dialogId,
);
if (!currentDialog) {
return;
}
const nextDialog = updater(currentDialog);
const nextDialogs = nextDialog
? dialogsRef.current.map((dialog) =>
dialog.id === dialogId ? nextDialog : dialog,
)
: dialogsRef.current.filter((dialog) => dialog.id !== dialogId);
dialogsRef.current = nextDialogs;
setDialogs(nextDialogs);
},
[],
);
const updateDialogs = useCallback(
(updater: SetStateAction<CanvasGenerationDialogState[]>) => {
const nextDialogs =
typeof updater === 'function' ? updater(dialogsRef.current) : updater;
dialogsRef.current = nextDialogs;
setDialogs(nextDialogs);
},
[],
);
const setPrompt = useCallback(
(dialogId: string, prompt: string) => {
updateCanvasGenerationDialogById(dialogId, (dialog) => ({
...dialog,
prompt,
}));
},
[updateCanvasGenerationDialogById],
);
const removeDialog = useCallback(
(dialogId: string) => {
updateDialogs((currentDialogs) =>
currentDialogs.filter((dialog) => dialog.id !== dialogId),
);
},
[updateDialogs],
);
const promptAssist = useImageCanvasSoundEffectPromptAssist({
canvasGenerationDialogs: dialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
return { dialogs, promptAssist, setPrompt, removeDialog };
}
describe('useImageCanvasSoundEffectPromptAssist', () => {
beforeEach(() => {
clientMocks.optimize.mockReset();
});
it('canonicalizes before await, collapses double clicks, and creates one undo snapshot', async () => {
const deferred = createDeferred<OptimizeResponse>();
clientMocks.optimize.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() =>
useHarness({
initialDialogs: [
createDialog('dialog-a', '\u0085金币落地声\u3000', {
status: 'failed',
errorMessage: '旧错误',
}),
],
currentUserId: 'user-a',
projectId: 'project-a',
}),
);
let firstAction!: ReturnType<
typeof result.current.promptAssist.optimizePrompt
>;
let duplicateResult!: Awaited<typeof firstAction>;
act(() => {
firstAction = result.current.promptAssist.optimizePrompt('dialog-a');
});
await act(async () => {
duplicateResult =
await result.current.promptAssist.optimizePrompt('dialog-a');
});
expect(clientMocks.optimize).toHaveBeenCalledTimes(1);
expect(clientMocks.optimize).toHaveBeenCalledWith(
{ currentPrompt: '金币落地声' },
{ signal: expect.any(AbortSignal) },
);
expect(requireDialog(result.current.dialogs, 'dialog-a')).toMatchObject({
prompt: '金币落地声',
status: 'idle',
errorMessage: undefined,
});
expect(duplicateResult.reason).toBe('duplicate');
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'optimizing',
undoPromptSnapshot: null,
temporaryPromptSnapshot: '金币落地声',
});
let actionResult!: Awaited<typeof firstAction>;
await act(async () => {
deferred.resolve({ prompt: '清脆明亮的金币落地声', charCount: 10 });
actionResult = await firstAction;
});
expect(actionResult.reason).toBe('applied');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'清脆明亮的金币落地声',
);
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'idle',
undoPromptSnapshot: '金币落地声',
temporaryPromptSnapshot: null,
});
act(() => {
result.current.setPrompt('dialog-a', '手动改过的结果');
result.current.promptAssist.undoPrompt('dialog-a');
});
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'金币落地声',
);
expect(
result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot,
).toBe('手动改过的结果');
});
it('rejects mismatched response counts without exposing the candidate or restoring an older snapshot', async () => {
clientMocks.optimize
.mockResolvedValueOnce({ prompt: '第一次优化结果', charCount: 7 })
.mockResolvedValueOnce({ prompt: '不应写入的候选', charCount: 999 });
const { result } = renderHook(() =>
useHarness({
initialDialogs: [createDialog('dialog-a', '原始描述')],
}),
);
await act(async () => {
await result.current.promptAssist.optimizePrompt('dialog-a');
});
act(() => result.current.setPrompt('dialog-a', '第二次输入'));
let actionResult!: Awaited<
ReturnType<typeof result.current.promptAssist.optimizePrompt>
>;
await act(async () => {
actionResult =
await result.current.promptAssist.optimizePrompt('dialog-a');
});
expect(actionResult).toMatchObject({ applied: false, reason: 'failed' });
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'第二次输入',
);
expect(JSON.stringify(result.current)).not.toContain('不应写入的候选');
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'idle',
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: 'AI 返回的游戏音效描述无效,请稍后重试',
});
});
it('drops a late response after account or project scope changes', async () => {
const deferred = createDeferred<OptimizeResponse>();
clientMocks.optimize.mockReturnValueOnce(deferred.promise);
const { result, rerender } = renderHook(
({ currentUserId, projectId }) =>
useHarness({
initialDialogs: [createDialog('dialog-a', '旧作用域输入')],
currentUserId,
projectId,
}),
{
initialProps: {
currentUserId: 'user-a',
projectId: 'project-a',
},
},
);
let action!: ReturnType<typeof result.current.promptAssist.optimizePrompt>;
act(() => {
action = result.current.promptAssist.optimizePrompt('dialog-a');
});
act(() => {
rerender({ currentUserId: 'user-b', projectId: 'project-b' });
result.current.setPrompt('dialog-a', '新作用域输入');
});
let actionResult!: Awaited<typeof action>;
await act(async () => {
deferred.resolve({ prompt: '旧响应候选', charCount: 6 });
actionResult = await action;
});
expect(actionResult.reason).toBe('stale');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'新作用域输入',
);
expect(JSON.stringify(result.current)).not.toContain('旧响应候选');
});
it('removes dialog state and ignores a response after dialog deletion', async () => {
const deferred = createDeferred<OptimizeResponse>();
clientMocks.optimize.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() =>
useHarness({ initialDialogs: [createDialog('dialog-a', '输入')] }),
);
let action!: ReturnType<typeof result.current.promptAssist.optimizePrompt>;
act(() => {
action = result.current.promptAssist.optimizePrompt('dialog-a');
result.current.removeDialog('dialog-a');
});
await act(async () => {
deferred.resolve({ prompt: '迟到候选', charCount: 4 });
await action;
});
expect(result.current.dialogs).toEqual([]);
expect(
result.current.promptAssist.dialogStates['dialog-a'],
).toBeUndefined();
});
it('freezes duration mode, manual value, and Loop in one synchronous submission claim', () => {
const { result } = renderHook(() =>
useHarness({
initialDialogs: [
createDialog('dialog-a', ' \u0085循环环境声\u3000', {
soundDurationMode: 'auto',
soundDurationSeconds: 7.3,
soundLoop: true,
}),
],
}),
);
let claim!: ReturnType<typeof result.current.promptAssist.beginSubmission>;
act(() => {
claim = result.current.promptAssist.beginSubmission('dialog-a');
});
expect(claim).toMatchObject({
prompt: '循环环境声',
durationMode: 'auto',
manualDurationSeconds: 7.3,
loop: true,
});
expect(result.current.promptAssist.getDialogState('dialog-a').status).toBe(
'submitting',
);
let duplicateClaim: ReturnType<
typeof result.current.promptAssist.beginSubmission
> = claim;
act(() => {
duplicateClaim = result.current.promptAssist.beginSubmission('dialog-a');
});
expect(duplicateClaim).toBeNull();
let finished = false;
act(() => {
finished = result.current.promptAssist.finishSubmission({
operation: claim!.operation,
accepted: false,
});
});
expect(finished).toBe(true);
expect(result.current.promptAssist.getDialogState('dialog-a').status).toBe(
'idle',
);
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'循环环境声',
);
});
it('clears the undo snapshot when a preset boundary is prepared', async () => {
clientMocks.optimize.mockResolvedValueOnce({
prompt: '优化结果',
charCount: 4,
});
const { result } = renderHook(() =>
useHarness({ initialDialogs: [createDialog('dialog-a', '原始输入')] }),
);
await act(async () => {
await result.current.promptAssist.optimizePrompt('dialog-a');
});
expect(
result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot,
).toBe('原始输入');
act(() => {
result.current.promptAssist.preparePreset('dialog-a');
});
await waitFor(() => {
expect(
result.current.promptAssist.getDialogState('dialog-a')
.undoPromptSnapshot,
).toBeNull();
});
});
});
File diff suppressed because it is too large Load Diff
+62
View File
@@ -16892,6 +16892,40 @@ button {
min-width: 0;
}
.image-canvas-editor__sound-effect-prompt-actions {
grid-column: 1 / -1;
}
.image-canvas-editor__sound-effect-prompt-action--optimize:not(:disabled) {
border-color: var(--image-canvas-brand-border-strong);
color: var(--image-canvas-brand-accent);
}
.image-canvas-editor__sound-effect-prompt-action--optimize:not(
:disabled
):hover {
background: var(--image-canvas-brand-soft);
}
.image-canvas-editor__sound-effect-auto-duration {
display: flex;
align-items: center;
gap: 0.45rem;
color: #475569;
font-size: 0.76rem;
font-weight: 700;
}
.image-canvas-editor__sound-effect-auto-duration input {
margin: 0;
}
.image-canvas-editor__sound-effect-loop[aria-pressed='true'] {
border-color: var(--image-canvas-brand-border-strong);
background: var(--image-canvas-brand-soft);
color: var(--image-canvas-brand-accent);
}
/*
* 助手动作按钮`.platform-button` 给的是 2.9rem 高与 0.7rem/1rem 内边距比同一面板里
* 2.25rem 的生成按钮高出一截这里按两级选择器压回去避免整排按钮比主操作还重
@@ -17122,6 +17156,34 @@ button {
color: #0f766e;
}
.image-canvas-editor__sound-effect-preset--ui-operation,
.image-canvas-editor__sound-effect-preset--pickup-reward {
border-color: rgba(37, 99, 235, 0.24);
background: rgba(219, 234, 254, 0.7);
color: #1d4ed8;
}
.image-canvas-editor__sound-effect-preset--growth-result,
.image-canvas-editor__sound-effect-preset--character-combat {
border-color: rgba(217, 119, 6, 0.24);
background: rgba(254, 243, 199, 0.7);
color: #b45309;
}
.image-canvas-editor__sound-effect-preset--skill-status,
.image-canvas-editor__sound-effect-preset--mechanism-scene-interaction {
border-color: rgba(13, 148, 136, 0.24);
background: rgba(204, 251, 241, 0.7);
color: #0f766e;
}
.image-canvas-editor__sound-effect-preset--style-direction,
.image-canvas-editor__sound-effect-preset--feedback-requirement {
border-color: rgba(124, 58, 237, 0.24);
background: rgba(237, 233, 254, 0.72);
color: #6d28d9;
}
.image-canvas-editor__background-music-presets-hairline {
position: absolute;
right: 1.74rem;

Some files were not shown because too many files have changed in this diff Show More