合并远端主分支更新
同步主分支音效生成与External v1契约更新 保留图标规范与图集生成链路 适配VectorEngine LLM客户端命名并解决前端提交语义冲突
This commit is contained in:
@@ -29,6 +29,28 @@ describe('PlatformFloatingMenu', () => {
|
||||
expect(onRename).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('drops menu semantics for the parameter options variant', () => {
|
||||
render(
|
||||
<PlatformFloatingMenu
|
||||
label="音效时长选项"
|
||||
variant="options"
|
||||
placement="top-start"
|
||||
>
|
||||
<label>
|
||||
<input type="checkbox" aria-label="自动时长" readOnly checked />
|
||||
自动时长
|
||||
</label>
|
||||
<input type="range" aria-label="手动音效时长" readOnly value={5} />
|
||||
</PlatformFloatingMenu>,
|
||||
);
|
||||
|
||||
// 复选框与滑块都不是 menu 的合法子角色,浮层必须退回普通分组语义。
|
||||
expect(screen.getByRole('group', { name: '音效时长选项' })).toBeTruthy();
|
||||
expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull();
|
||||
expect(screen.getByRole('checkbox', { name: '自动时长' })).toBeTruthy();
|
||||
expect(screen.getByRole('slider', { name: '手动音效时长' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps pointer events from leaking to canvas-style parents', () => {
|
||||
const onParentPointerDown = vi.fn();
|
||||
const onMenuPointerDown = vi.fn();
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* `menu` 承载纯动作集合,子节点必须全是 `PlatformFloatingMenuItem`;
|
||||
* `options` 承载参数控件(复选框、滑块、选项按钮),它们不是 menuitem,
|
||||
* 放进 `role="menu"` 既违反 ARIA 子角色约束,也会让读屏进入菜单模式后读不到滑块。
|
||||
*/
|
||||
type PlatformFloatingMenuVariant = 'menu' | 'options';
|
||||
|
||||
type PlatformFloatingMenuProps = HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
label?: string;
|
||||
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
||||
style?: CSSProperties;
|
||||
variant?: PlatformFloatingMenuVariant;
|
||||
};
|
||||
|
||||
type PlatformFloatingMenuItemProps = Omit<
|
||||
@@ -26,6 +34,7 @@ export function PlatformFloatingMenu({
|
||||
label,
|
||||
placement = 'top-end',
|
||||
style,
|
||||
variant = 'menu',
|
||||
onPointerDown,
|
||||
...divProps
|
||||
}: PlatformFloatingMenuProps) {
|
||||
@@ -39,7 +48,7 @@ export function PlatformFloatingMenu({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="menu"
|
||||
role={variant === 'options' ? 'group' : 'menu'}
|
||||
aria-label={label}
|
||||
style={style}
|
||||
onPointerDown={(event) => {
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
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}
|
||||
// 锁定要停的是整条轨道的滚动,不只是自动循环:轨道本身仍是可滚动容器,
|
||||
// 不收紧就还能被触摸、触控板和 Shift+滚轮横向拖动。
|
||||
className={`image-canvas-editor__background-music-presets-viewport${
|
||||
isLocked
|
||||
? ' image-canvas-editor__background-music-presets-viewport--locked'
|
||||
: ''
|
||||
}`}
|
||||
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,5 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -9,6 +12,24 @@ import { BACKGROUND_MUSIC_PROMPT_PRESETS } from './ImageCanvasBackgroundMusicPre
|
||||
|
||||
const VIEWPORT_WIDTH = 600;
|
||||
const QUEUE_WIDTH = 1800;
|
||||
const LOCKED_VIEWPORT_CLASS =
|
||||
'image-canvas-editor__background-music-presets-viewport--locked';
|
||||
|
||||
// jsdom 不加载样式表,只断言 class 名会让「改了 CSS 选择器但没改组件」照样绿。
|
||||
// 这里把类名与真正生效的规则绑在一起。
|
||||
function readLockedViewportCssRule() {
|
||||
// jsdom 环境下 import.meta.url 不是 file 协议,只能按仓库根解析。
|
||||
const stylesheet = readFileSync(
|
||||
path.resolve(process.cwd(), 'src/index.css'),
|
||||
'utf8',
|
||||
);
|
||||
const ruleStart = stylesheet.indexOf(`.${LOCKED_VIEWPORT_CLASS} {`);
|
||||
expect(ruleStart).toBeGreaterThanOrEqual(0);
|
||||
const bodyStart = stylesheet.indexOf('{', ruleStart) + 1;
|
||||
const bodyEnd = stylesheet.indexOf('}', bodyStart);
|
||||
expect(bodyEnd).toBeGreaterThan(bodyStart);
|
||||
return stylesheet.slice(bodyStart, bodyEnd);
|
||||
}
|
||||
|
||||
type FrameCallback = (timestamp: number) => void;
|
||||
|
||||
@@ -438,6 +459,46 @@ describe('ImageCanvasBackgroundMusicPresetMarquee', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('stops the track from being scrolled by hand while the panel is locked', () => {
|
||||
const onSelectPreset = vi.fn();
|
||||
const { container, rerender } = renderMarquee({ onSelectPreset });
|
||||
expand();
|
||||
const viewport = getViewport(container);
|
||||
|
||||
// 修饰类必须真的对应一条把轨道收紧成不可滚动容器的规则。
|
||||
const lockedRule = readLockedViewportCssRule();
|
||||
expect(lockedRule).toContain('overflow-x: hidden;');
|
||||
expect(lockedRule).toContain('touch-action: pan-y;');
|
||||
|
||||
// 未锁定时轨道就是可滚动容器,交给原生横向滚动。
|
||||
expect(viewport.classList.contains(LOCKED_VIEWPORT_CLASS)).toBe(false);
|
||||
|
||||
rerender(
|
||||
<ImageCanvasBackgroundMusicPresetMarquee
|
||||
presets={BACKGROUND_MUSIC_PROMPT_PRESETS}
|
||||
isLocked
|
||||
onSelectPreset={onSelectPreset}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 锁定时不只停自动循环,轨道本身也不再接受触摸 / 触控板的横向滚动。
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
expect(viewport.classList.contains(LOCKED_VIEWPORT_CLASS)).toBe(true);
|
||||
// 相位保留:解锁后从原处继续,不跳回队列头。
|
||||
expect(viewport.scrollLeft).toBe(QUEUE_WIDTH);
|
||||
|
||||
rerender(
|
||||
<ImageCanvasBackgroundMusicPresetMarquee
|
||||
presets={BACKGROUND_MUSIC_PROMPT_PRESETS}
|
||||
isLocked={false}
|
||||
onSelectPreset={onSelectPreset}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(viewport.classList.contains(LOCKED_VIEWPORT_CLASS)).toBe(false);
|
||||
expect(viewport.scrollLeft).toBe(QUEUE_WIDTH);
|
||||
});
|
||||
|
||||
it('never starts the loop under reduced motion and reacts to runtime changes', () => {
|
||||
prefersReducedMotion = true;
|
||||
const { container } = renderMarquee();
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
dropDeadInlineGenerationPlaceholders,
|
||||
formatCanvasDisplayScalePercent,
|
||||
generationInputsOrNull,
|
||||
hydrateCanvasGenerationDialog,
|
||||
hydrateLayer,
|
||||
INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS,
|
||||
@@ -971,6 +972,48 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(hydrated).not.toHaveProperty('durationSeconds');
|
||||
});
|
||||
|
||||
it('keeps only valid authoritative SFX V2 metadata in generation inputs', () => {
|
||||
const soundEffect = {
|
||||
schemaVersion: 2 as const,
|
||||
userPrompt: '金币落地,轻快可爱',
|
||||
actualPrompt: 'A bright, cute coin landing chime',
|
||||
model: 'eleven_text_to_sound_v2' as const,
|
||||
durationMode: 'manual' as const,
|
||||
requestedDurationSeconds: 5,
|
||||
actualDurationSeconds: 5.12,
|
||||
loop: false,
|
||||
};
|
||||
expect(
|
||||
generationInputsOrNull({ fields: [], references: [], soundEffect }),
|
||||
).toEqual({ fields: [], references: [], soundEffect });
|
||||
|
||||
for (const invalidSoundEffect of [
|
||||
{ ...soundEffect, schemaVersion: 1 },
|
||||
{ ...soundEffect, model: 'audio1.0' },
|
||||
{ ...soundEffect, userPrompt: ' 金币落地' },
|
||||
{ ...soundEffect, userPrompt: '\uFEFF金币落地,轻快可爱' },
|
||||
{ ...soundEffect, actualDurationSeconds: 600.1 },
|
||||
{
|
||||
...soundEffect,
|
||||
durationMode: 'auto',
|
||||
requestedDurationSeconds: 5,
|
||||
},
|
||||
{
|
||||
...soundEffect,
|
||||
durationMode: 'manual',
|
||||
requestedDurationSeconds: null,
|
||||
},
|
||||
]) {
|
||||
expect(
|
||||
generationInputsOrNull({
|
||||
fields: [{ title: '用户描述', value: '金币落地' }],
|
||||
references: [],
|
||||
soundEffect: invalidSoundEffect,
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('hydrates character animation sequence fields from the project resource', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-action',
|
||||
@@ -1088,7 +1131,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,
|
||||
@@ -1112,7 +1158,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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
EDITOR_SOUND_EFFECT_MODEL,
|
||||
SOUND_EFFECT_DURATION_MAX_SECONDS,
|
||||
SOUND_EFFECT_DURATION_MIN_SECONDS,
|
||||
} from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import { isEditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene';
|
||||
import type {
|
||||
EditorAssetGenerationInputs,
|
||||
@@ -22,6 +27,7 @@ import type {
|
||||
PerfectPixelOperationSnapshot,
|
||||
SnapCandidate,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
|
||||
|
||||
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
||||
{
|
||||
@@ -1357,11 +1363,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
|
||||
@@ -2017,7 +2039,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) {
|
||||
@@ -2173,6 +2198,7 @@ export function generationInputsOrNull(
|
||||
const snapshot = value as {
|
||||
fields?: unknown;
|
||||
references?: unknown;
|
||||
soundEffect?: unknown;
|
||||
};
|
||||
const fields = Array.isArray(snapshot.fields)
|
||||
? snapshot.fields.flatMap((field) => {
|
||||
@@ -2209,7 +2235,81 @@ export function generationInputsOrNull(
|
||||
})
|
||||
: [];
|
||||
|
||||
return fields.length || references.length ? { fields, references } : null;
|
||||
const hasSoundEffect = Object.prototype.hasOwnProperty.call(
|
||||
snapshot,
|
||||
'soundEffect',
|
||||
);
|
||||
const soundEffect = hasSoundEffect
|
||||
? soundEffectGenerationMetadataOrNull(snapshot.soundEffect)
|
||||
: null;
|
||||
if (hasSoundEffect && !soundEffect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fields.length || references.length || soundEffect
|
||||
? {
|
||||
fields,
|
||||
references,
|
||||
...(soundEffect ? { soundEffect } : {}),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function soundEffectGenerationMetadataOrNull(
|
||||
value: unknown,
|
||||
): NonNullable<CanvasGenerationInputs['soundEffect']> | null {
|
||||
if (!isSnapshotRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const userPrompt =
|
||||
typeof value.userPrompt === 'string' ? value.userPrompt : '';
|
||||
const actualPrompt =
|
||||
typeof value.actualPrompt === 'string' ? value.actualPrompt : '';
|
||||
const userPromptValidation = validateSoundEffectPrompt(userPrompt);
|
||||
const actualPromptValidation = validateSoundEffectPrompt(actualPrompt);
|
||||
if (
|
||||
value.schemaVersion !== 2 ||
|
||||
value.model !== EDITOR_SOUND_EFFECT_MODEL ||
|
||||
(value.durationMode !== 'auto' && value.durationMode !== 'manual') ||
|
||||
typeof value.loop !== 'boolean' ||
|
||||
!userPromptValidation.ok ||
|
||||
userPromptValidation.prompt !== userPrompt ||
|
||||
!actualPromptValidation.ok ||
|
||||
actualPromptValidation.prompt !== actualPrompt ||
|
||||
typeof value.actualDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(value.actualDurationSeconds) ||
|
||||
value.actualDurationSeconds <= 0 ||
|
||||
value.actualDurationSeconds > 600
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let requestedDurationSeconds: number | null;
|
||||
if (value.durationMode === 'auto') {
|
||||
if (value.requestedDurationSeconds !== null) {
|
||||
return null;
|
||||
}
|
||||
requestedDurationSeconds = null;
|
||||
} else {
|
||||
if (
|
||||
typeof value.requestedDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(value.requestedDurationSeconds) ||
|
||||
value.requestedDurationSeconds < SOUND_EFFECT_DURATION_MIN_SECONDS ||
|
||||
value.requestedDurationSeconds > SOUND_EFFECT_DURATION_MAX_SECONDS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
requestedDurationSeconds = value.requestedDurationSeconds;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
userPrompt,
|
||||
actualPrompt,
|
||||
model: EDITOR_SOUND_EFFECT_MODEL,
|
||||
durationMode: value.durationMode,
|
||||
requestedDurationSeconds,
|
||||
actualDurationSeconds: value.actualDurationSeconds,
|
||||
loop: value.loop,
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
EditorSoundEffectDurationMode,
|
||||
EditorSoundEffectGenerationMetadataV2,
|
||||
EditorSoundEffectModel,
|
||||
} from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import type { EditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
@@ -85,6 +90,7 @@ export type CanvasGenerationInputReference = {
|
||||
export type CanvasGenerationInputs = {
|
||||
fields: CanvasGenerationInputField[];
|
||||
references: CanvasGenerationInputReference[];
|
||||
soundEffect?: EditorSoundEffectGenerationMetadataV2;
|
||||
};
|
||||
|
||||
export type CanvasLayer = {
|
||||
@@ -253,9 +259,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;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
formatEditorGenerationInputFieldValue,
|
||||
formatLayerImageType,
|
||||
getEditorLayerModelDisplayName,
|
||||
isEditorUserVisibleGenerationInputField,
|
||||
@@ -553,7 +554,7 @@ function buildVisibleGenerationInputs(layer: CanvasLayer) {
|
||||
.filter((field) => !isBuiltInGenerationInputField(field))
|
||||
.map((field) => ({
|
||||
title: field.title,
|
||||
value: field.value,
|
||||
value: formatEditorGenerationInputFieldValue(field),
|
||||
})) ?? [];
|
||||
const references =
|
||||
layer.generationInputs?.references.map((reference) => ({
|
||||
|
||||
@@ -24,6 +24,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>>;
|
||||
@@ -57,6 +61,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' },
|
||||
@@ -210,6 +215,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(),
|
||||
@@ -1085,21 +1116,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>>
|
||||
}
|
||||
@@ -1119,39 +1169,52 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
'.image-canvas-editor__generation-composer-footer',
|
||||
);
|
||||
expect(footer).toBeTruthy();
|
||||
// 底部一行:左侧一键优化与时长、循环,右侧 ElevenLabs + 生成。
|
||||
expect(footer?.children[0]?.className).toContain(
|
||||
'image-canvas-editor__option-popover-anchor--dimensions',
|
||||
'image-canvas-editor__sound-effect-prompt-actions',
|
||||
);
|
||||
expect(footer?.children[1]?.className).toContain(
|
||||
'image-canvas-editor__option-popover-anchor--model',
|
||||
'image-canvas-editor__option-popover-anchor--dimensions',
|
||||
);
|
||||
expect(footer?.children[2]?.className).toContain(
|
||||
'image-canvas-editor__sound-effect-loop',
|
||||
);
|
||||
expect(footer?.children[3]?.className).toContain(
|
||||
'image-canvas-editor__option-popover-anchor--model',
|
||||
);
|
||||
expect(footer?.children[4]?.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: '循环 关' })
|
||||
.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泥点');
|
||||
@@ -1159,22 +1222,34 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '音效时长 5秒' }),
|
||||
);
|
||||
const optionPanel = screen.getByRole('menu', { name: '音效时长选项' });
|
||||
const optionPanel = screen.getByRole('group', { 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: '循环 关' }));
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '生成游戏音效' }),
|
||||
@@ -1183,10 +1258,60 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
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');
|
||||
// AutoGrowTextArea 改用 Lexical contentEditable 后不再有原生 readOnly 属性,
|
||||
// 锁定态由 aria-readonly 暴露(同时 editor.setEditable(false))。
|
||||
expect(
|
||||
within(panel)
|
||||
.getByRole('textbox', { name: 'prompt' })
|
||||
.getAttribute('aria-readonly'),
|
||||
).toBe('true');
|
||||
for (const name of [
|
||||
'展开',
|
||||
'一键优化',
|
||||
'撤销',
|
||||
'音效时长 5秒',
|
||||
'循环 关',
|
||||
'生成游戏音效',
|
||||
]) {
|
||||
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(
|
||||
{
|
||||
@@ -1611,13 +1736,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: '生成游戏音效' });
|
||||
@@ -1674,11 +1800,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, {
|
||||
@@ -1686,7 +1813,7 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '音效时长 5秒' }));
|
||||
expect(screen.getByRole('menu', { name: '音效时长选项' })).toBeTruthy();
|
||||
expect(screen.getByRole('group', { name: '音效时长选项' })).toBeTruthy();
|
||||
|
||||
const backgroundMusicDialog = createBackgroundMusicDialog({
|
||||
id: 'dialog-bgm-locked',
|
||||
@@ -1700,7 +1827,7 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull();
|
||||
expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull();
|
||||
expect(getBackgroundMusicPanel().getAttribute('aria-busy')).toBe('true');
|
||||
|
||||
view.rerender(
|
||||
@@ -1726,7 +1853,77 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false);
|
||||
expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull();
|
||||
});
|
||||
|
||||
it('音效时长浮层是参数分组,打开后移焦并支持 Escape 关闭回焦', () => {
|
||||
const soundEffectDialog: GenerateDialogState = {
|
||||
id: 'dialog-sfx-options-a11y',
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '开门声',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: 'eleven_text_to_sound_v2',
|
||||
soundDurationSeconds: 5,
|
||||
};
|
||||
renderComposer(soundEffectDialog);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '音效时长 5秒' });
|
||||
fireEvent.click(trigger);
|
||||
|
||||
// 复选框和滑块不是 menuitem,浮层不能再声明成菜单。
|
||||
const optionPanel = screen.getByRole('group', { name: '音效时长选项' });
|
||||
expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull();
|
||||
expect(within(optionPanel).queryAllByRole('menuitem')).toHaveLength(0);
|
||||
|
||||
// 浮层 portal 到 body,必须主动移焦,否则参数控件键盘不可达。
|
||||
const automaticDuration = within(optionPanel).getByRole('checkbox', {
|
||||
name: '自动时长',
|
||||
});
|
||||
expect(document.activeElement).toBe(automaticDuration);
|
||||
|
||||
// 画布全局快捷键在 window 上监听 Escape 并会关掉整个生成 dialog;浮层打开时
|
||||
// 这一次 Escape 必须被浮层吃掉,不能冒泡到 window。
|
||||
const globalEscape = vi.fn();
|
||||
window.addEventListener('keydown', globalEscape);
|
||||
try {
|
||||
fireEvent.keyDown(automaticDuration, { key: 'Escape' });
|
||||
|
||||
expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
expect(globalEscape).not.toHaveBeenCalled();
|
||||
|
||||
// 浮层关闭后 Escape 重新归全局快捷键所有。
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' });
|
||||
expect(globalEscape).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
window.removeEventListener('keydown', globalEscape);
|
||||
}
|
||||
});
|
||||
|
||||
it('点击浮层外部只收起时长浮层,不抢走焦点', () => {
|
||||
const soundEffectDialog: GenerateDialogState = {
|
||||
id: 'dialog-sfx-options-outside-click',
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '开门声',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: 'eleven_text_to_sound_v2',
|
||||
soundDurationSeconds: 5,
|
||||
};
|
||||
renderComposer(soundEffectDialog);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '音效时长 5秒' });
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole('group', { name: '音效时长选项' })).toBeTruthy();
|
||||
|
||||
const promptInput = screen.getByRole('textbox', { name: 'prompt' });
|
||||
promptInput.focus();
|
||||
fireEvent.click(promptInput);
|
||||
|
||||
// 外部点击只关闭浮层;还焦是 Escape 专属,否则会把用户从输入框拽回触发按钮。
|
||||
expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull();
|
||||
expect(document.activeElement).toBe(promptInput);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Cpu,
|
||||
Film,
|
||||
ImageIcon,
|
||||
Music,
|
||||
Repeat,
|
||||
Sparkles,
|
||||
Undo2,
|
||||
WandSparkles,
|
||||
@@ -16,11 +18,16 @@ import {
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
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 {
|
||||
@@ -84,9 +91,23 @@ import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMateria
|
||||
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import { ImageCanvasSceneStyleControl } from './ImageCanvasSceneStyleControl';
|
||||
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>;
|
||||
@@ -112,6 +133,7 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
backgroundMusicPromptAssist?: BackgroundMusicPromptAssistComposerController;
|
||||
soundEffectPromptAssist?: SoundEffectPromptAssistComposerController;
|
||||
updateCanvasGenerationDialogById: (
|
||||
dialogId: string,
|
||||
updater: (
|
||||
@@ -773,15 +795,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 = '生成游戏背景音乐';
|
||||
@@ -794,22 +811,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: (
|
||||
@@ -820,6 +837,7 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
}) {
|
||||
const [isSoundOptionsOpen, setIsSoundOptionsOpen] = useState(false);
|
||||
const soundOptionsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const soundAutoDurationRef = useRef<HTMLInputElement | null>(null);
|
||||
const soundModelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const promptCounterId = useId();
|
||||
const isSoundEffect = dialog.mode === 'audio-sound-effect';
|
||||
@@ -829,8 +847,17 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
isOpen: isSoundEffect && isSoundOptionsOpen,
|
||||
boundaryRefs: [soundOptionsButtonRef],
|
||||
onDismiss: () => setIsSoundOptionsOpen(false),
|
||||
restoreFocusRef: soundOptionsButtonRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSoundEffect || !isSoundOptionsOpen) {
|
||||
return;
|
||||
}
|
||||
// 浮层 portal 到 body,Tab 顺序离触发按钮很远;不主动移焦,复选框和滑块键盘不可达。
|
||||
soundAutoDurationRef.current?.focus();
|
||||
}, [isSoundEffect, isSoundOptionsOpen]);
|
||||
|
||||
if (!isSoundEffect) {
|
||||
const backgroundMusicDialog = toBackgroundMusicGenerationDialog(dialog);
|
||||
const backgroundMusicPromptAssist = promptAssist;
|
||||
@@ -1079,23 +1106,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,
|
||||
@@ -1110,21 +1181,51 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio"
|
||||
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio image-canvas-editor__generation-composer--sound-effect"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label={dialogLabel}
|
||||
aria-busy={isLocked}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<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"
|
||||
onValueChange={(value) => updateAudioDialog({ prompt: 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"
|
||||
@@ -1132,27 +1233,86 @@ 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__generation-composer-footer">
|
||||
<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__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
|
||||
<PlatformInlineOptionButton
|
||||
ref={soundOptionsButtonRef}
|
||||
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)}
|
||||
>
|
||||
{soundOptionLabel}
|
||||
<span
|
||||
className="image-canvas-editor__sound-effect-option-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Clock />
|
||||
</span>
|
||||
<span>{soundOptionLabel}</span>
|
||||
</PlatformInlineOptionButton>
|
||||
{isSoundOptionsOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
|
||||
label="音效时长选项"
|
||||
variant="options"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
soundOptionsButtonRef.current,
|
||||
@@ -1164,15 +1324,32 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
时长
|
||||
</span>
|
||||
<label className="image-canvas-editor__sound-effect-auto-duration">
|
||||
<input
|
||||
ref={soundAutoDurationRef}
|
||||
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))
|
||||
}
|
||||
@@ -1185,6 +1362,24 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__sound-effect-loop"
|
||||
aria-label={`循环 ${soundLoop ? '开' : '关'}`}
|
||||
aria-pressed={soundLoop}
|
||||
disabled={isLocked}
|
||||
onClick={() => updateAudioDialog({ soundLoop: !soundLoop })}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__sound-effect-option-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Repeat />
|
||||
</span>
|
||||
<span>循环</span>
|
||||
<span className="image-canvas-editor__sound-effect-loop-state">
|
||||
{soundLoop ? '开' : '关'}
|
||||
</span>
|
||||
</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}
|
||||
@@ -1209,11 +1404,15 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={isGenerating}
|
||||
disabled={!canGenerate}
|
||||
aria-label={dialogLabel}
|
||||
onClick={() => {
|
||||
if (!isGenerating) {
|
||||
onSubmit(dialog);
|
||||
// 与 disabled 用同一个 canGenerate 做冗余守卫:原生 disabled 已经挡住点击,
|
||||
// 但若将来去掉 disabled 或换成非原生按钮,空提示词、超限和优化中仍不能把
|
||||
// 付费请求发出去。soundEffectDialog 与 dialog 值相同,这里取前者只为与
|
||||
// 本分支其余 SFX 代码保持一致。
|
||||
if (canGenerate) {
|
||||
onSubmit(soundEffectDialog);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1256,6 +1455,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
isPickingUiDesignSpecFromCanvas,
|
||||
generateDialog,
|
||||
backgroundMusicPromptAssist,
|
||||
soundEffectPromptAssist,
|
||||
updateCanvasGenerationDialogById,
|
||||
generationComposerStyle,
|
||||
iconComposerStyle,
|
||||
@@ -1499,7 +1699,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
dialog={generateDialog}
|
||||
style={generationComposerStyle}
|
||||
promptAssist={backgroundMusicPromptAssist}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
soundEffectPromptAssist={soundEffectPromptAssist}
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
updateCanvasGenerationDialogById={updateCanvasGenerationDialogById}
|
||||
|
||||
@@ -266,8 +266,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: 'audio1.0',
|
||||
soundModel: 'eleven_text_to_sound_v2',
|
||||
soundDurationMode: 'auto',
|
||||
soundDurationSeconds: 5,
|
||||
soundLoop: false,
|
||||
placeholder: {
|
||||
x: 270,
|
||||
y: 300,
|
||||
@@ -326,8 +328,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,
|
||||
@@ -385,6 +389,46 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores authoritative SFX V2 prompt, duration mode, and loop on redraw', () => {
|
||||
const draft = createAudioRedrawGenerationDialogDraft(
|
||||
createLayer({
|
||||
id: 'layer-sfx-v2',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
prompt: '不应覆盖权威用户描述',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '用户描述', value: '金币落地' },
|
||||
{ title: '实际英文提示词', value: 'A bright coin landing chime' },
|
||||
{ title: '时长', value: '7.42秒' },
|
||||
{ title: 'Loop', value: '开启' },
|
||||
],
|
||||
references: [],
|
||||
soundEffect: {
|
||||
schemaVersion: 2,
|
||||
userPrompt: '金币落地',
|
||||
actualPrompt: 'A bright coin landing chime',
|
||||
model: 'eleven_text_to_sound_v2',
|
||||
durationMode: 'auto',
|
||||
requestedDurationSeconds: null,
|
||||
actualDurationSeconds: 7.42,
|
||||
loop: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
mode: 'audio-sound-effect',
|
||||
sourceLayerId: 'layer-sfx-v2',
|
||||
prompt: '金币落地',
|
||||
soundModel: 'eleven_text_to_sound_v2',
|
||||
soundDurationMode: 'auto',
|
||||
soundDurationSeconds: 5,
|
||||
soundLoop: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates edit, quick-edit, and character animation panel drafts', () => {
|
||||
const sourceLayer = createLayer({
|
||||
prompt: '【系统内置】完整拼接提示词:原图提示,自动画质优化。',
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
SOUND_EFFECT_DURATION_MAX_SECONDS,
|
||||
SOUND_EFFECT_DURATION_MIN_SECONDS,
|
||||
} from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import {
|
||||
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
|
||||
resolveEditorSceneStylePresetByLabel,
|
||||
@@ -442,7 +446,10 @@ export function createSoundEffectGenerationDialogDraft({
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: DEFAULT_SOUND_EFFECT_MODEL,
|
||||
// 与设计稿一致:新建音效默认自动时长,由模型按描述决定长度。
|
||||
soundDurationMode: 'auto',
|
||||
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,
|
||||
@@ -508,6 +515,7 @@ const USER_PROMPT_INPUT_TITLES = new Set(
|
||||
'快速编辑提示词',
|
||||
'重绘提示词',
|
||||
'动作描述',
|
||||
'用户描述',
|
||||
'画面内容',
|
||||
].map((title) => title.toLowerCase()),
|
||||
);
|
||||
@@ -545,6 +553,14 @@ function resolveUserGenerationPromptSnapshot(sourceLayer: CanvasLayer) {
|
||||
}
|
||||
|
||||
function resolveAudioRedrawPrompt(sourceLayer: CanvasLayer) {
|
||||
const soundEffect = sourceLayer.generationInputs?.soundEffect;
|
||||
if (
|
||||
sourceLayer.assetKind === 'sound-effect' &&
|
||||
soundEffect?.schemaVersion === 2 &&
|
||||
soundEffect.userPrompt.trim()
|
||||
) {
|
||||
return soundEffect.userPrompt;
|
||||
}
|
||||
return resolveUserGenerationPromptSnapshot(sourceLayer);
|
||||
}
|
||||
|
||||
@@ -564,26 +580,37 @@ 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) {
|
||||
const soundEffect = sourceLayer.generationInputs?.soundEffect;
|
||||
if (soundEffect?.schemaVersion === 2) {
|
||||
if (
|
||||
soundEffect.durationMode === 'manual' &&
|
||||
typeof soundEffect.requestedDurationSeconds === 'number' &&
|
||||
Number.isFinite(soundEffect.requestedDurationSeconds)
|
||||
) {
|
||||
return soundEffect.requestedDurationSeconds;
|
||||
}
|
||||
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
|
||||
}
|
||||
const fieldValue = findGenerationInputFieldValue(sourceLayer, [
|
||||
'时长',
|
||||
'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(
|
||||
@@ -619,7 +646,16 @@ export function createAudioRedrawGenerationDialogDraft(
|
||||
return {
|
||||
...baseDraft,
|
||||
soundModel: resolveAudioRedrawSoundModel(sourceLayer),
|
||||
soundDurationMode:
|
||||
sourceLayer.generationInputs?.soundEffect?.schemaVersion === 2 &&
|
||||
sourceLayer.generationInputs.soundEffect.durationMode === 'auto'
|
||||
? 'auto'
|
||||
: 'manual',
|
||||
soundDurationSeconds: resolveAudioRedrawSoundDuration(sourceLayer),
|
||||
soundLoop:
|
||||
sourceLayer.generationInputs?.soundEffect?.schemaVersion === 2
|
||||
? sourceLayer.generationInputs.soundEffect.loop
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1106,8 +1142,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,
|
||||
};
|
||||
|
||||
@@ -512,7 +512,8 @@ export function createAudioResultLayer({
|
||||
objectKey: resource?.objectKey ?? generated.objectKey,
|
||||
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
|
||||
sourceAssetId: asset?.assetId,
|
||||
generationInputs: resource?.generationInputs ?? generationInputs,
|
||||
generationInputs:
|
||||
resource?.generationInputs ?? asset?.generationInputs ?? generationInputs,
|
||||
generatedAssetSnapshot: asset ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_VIDEO_MODEL_OPTIONS,
|
||||
formatEditorGenerationInputFieldValue,
|
||||
getGenerationFrameAriaLabel,
|
||||
getGenerationFrameLabel,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -61,6 +62,23 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('展示时长时收敛到两位小数且不改动其他字段', () => {
|
||||
const format = (title: string, value: string) =>
|
||||
formatEditorGenerationInputFieldValue({ title, value });
|
||||
|
||||
expect(format('时长', '8.306938775510204秒')).toBe('8.31秒');
|
||||
expect(format('时长', '3.4512秒')).toBe('3.45秒');
|
||||
// 整数与一位小数不补零,"自动"这类无数字取值原样保留。
|
||||
expect(format('时长', '5秒')).toBe('5秒');
|
||||
expect(format('时长', '2.5秒')).toBe('2.5秒');
|
||||
expect(format('时长', '自动')).toBe('自动');
|
||||
expect(format('duration', '8.306938775510204s')).toBe('8.31s');
|
||||
// 非时长字段一律原样返回,不能误伤提示词里的数字。
|
||||
expect(format('用户描述', '持续 8.306938775510204 秒的低鸣')).toBe(
|
||||
'持续 8.306938775510204 秒的低鸣',
|
||||
);
|
||||
});
|
||||
|
||||
it('为所有编辑器生成模型提供显式定价配置', () => {
|
||||
expect(
|
||||
EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter(
|
||||
@@ -531,12 +549,18 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
'Quick Edit Generator',
|
||||
);
|
||||
expect(
|
||||
buildSoundEffectGenerationInputs('金币掉落叮当声', 'audio1.0', 7),
|
||||
buildSoundEffectGenerationInputs(
|
||||
'金币掉落叮当声',
|
||||
'eleven_text_to_sound_v2',
|
||||
7,
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
fields: [
|
||||
{ title: 'prompt', value: '金币掉落叮当声' },
|
||||
{ title: 'model', value: 'audio1.0' },
|
||||
{ title: '时长', value: '7秒' },
|
||||
{ title: '用户描述', value: '金币掉落叮当声' },
|
||||
{ title: 'model', value: 'eleven_text_to_sound_v2' },
|
||||
{ title: '请求时长', value: '7秒' },
|
||||
{ title: 'Loop', value: '开启' },
|
||||
],
|
||||
references: [],
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EDITOR_SOUND_EFFECT_MODEL } from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import {
|
||||
CUSTOM_EDITOR_SCENE_STYLE_PRESET,
|
||||
getEditorSceneStylePresetLabel,
|
||||
@@ -320,7 +321,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 },
|
||||
@@ -358,6 +359,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 = {
|
||||
@@ -400,6 +402,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:
|
||||
@@ -412,7 +419,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,
|
||||
@@ -625,6 +632,30 @@ export function isEditorUserVisibleGenerationInputField(
|
||||
return field.title.trim() !== '处理模型';
|
||||
}
|
||||
|
||||
const GENERATION_INPUT_DURATION_TITLES = new Set(['时长', 'duration']);
|
||||
const GENERATION_INPUT_DURATION_DECIMALS = 2;
|
||||
|
||||
/**
|
||||
* 时长字段落库的是模型返回的全精度秒数(例如 8.306938775510204 秒),展示时收敛到两位
|
||||
* 小数。只作用于渲染:底层字段保持原值,重绘默认时长与作品时长推断仍按原精度解析。
|
||||
*/
|
||||
export function formatEditorGenerationInputFieldValue(
|
||||
field: CanvasGenerationInputField,
|
||||
) {
|
||||
if (!GENERATION_INPUT_DURATION_TITLES.has(field.title.trim().toLowerCase())) {
|
||||
return field.value;
|
||||
}
|
||||
// 只改写首个数字,"自动"这类无数字取值和"秒"后缀都原样保留。
|
||||
return field.value.replace(/\d+(?:\.\d+)?/u, (matched) => {
|
||||
const parsed = Number.parseFloat(matched);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return matched;
|
||||
}
|
||||
const factor = 10 ** GENERATION_INPUT_DURATION_DECIMALS;
|
||||
return String(Math.round(parsed * factor) / factor);
|
||||
});
|
||||
}
|
||||
|
||||
export function getEditorLayerModelDisplayName(
|
||||
model: string | null | undefined,
|
||||
) {
|
||||
@@ -1151,13 +1182,18 @@ export function buildVideoGenerationInputs(
|
||||
export function buildSoundEffectGenerationInputs(
|
||||
prompt: string,
|
||||
model: string,
|
||||
duration: number,
|
||||
duration: number | null,
|
||||
loop: boolean,
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: [
|
||||
...createGenerationInputField('prompt', prompt),
|
||||
...createGenerationInputField('用户描述', prompt),
|
||||
...createGenerationInputField('model', model),
|
||||
...createGenerationInputField('时长', `${duration}秒`),
|
||||
...createGenerationInputField(
|
||||
'请求时长',
|
||||
duration === null ? '自动' : `${duration}秒`,
|
||||
),
|
||||
...createGenerationInputField('Loop', loop ? '开启' : '关闭'),
|
||||
],
|
||||
references: [],
|
||||
};
|
||||
|
||||
@@ -1179,6 +1179,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
prompt: ' 金币掉落叮当声 ',
|
||||
status: 'idle',
|
||||
soundDurationSeconds: 7,
|
||||
soundDurationMode: 'manual',
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 4,
|
||||
@@ -1190,16 +1191,18 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
normalizedPrompt: '金币掉落叮当声',
|
||||
input: {
|
||||
prompt: '金币掉落叮当声',
|
||||
model: 'audio1.0',
|
||||
model: 'eleven_text_to_sound_v2',
|
||||
duration: 7,
|
||||
loop: false,
|
||||
},
|
||||
result: {
|
||||
title: '游戏音效 4',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: 'prompt', value: '金币掉落叮当声' },
|
||||
{ title: 'model', value: 'audio1.0' },
|
||||
{ title: '时长', value: '7秒' },
|
||||
{ title: '用户描述', value: '金币掉落叮当声' },
|
||||
{ title: 'model', value: 'eleven_text_to_sound_v2' },
|
||||
{ title: '请求时长', value: '7秒' },
|
||||
{ title: 'Loop', value: '关闭' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
@@ -1207,38 +1210,60 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the game sound effect fallback for an all-whitespace prompt', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
it('preserves frozen auto duration, loop, and manual decimal precision', () => {
|
||||
const automatic = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: ' \t\r\n ',
|
||||
prompt: '环境循环音',
|
||||
status: 'idle',
|
||||
soundDurationMode: 'auto',
|
||||
soundDurationSeconds: 1.23456789,
|
||||
soundLoop: true,
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 6,
|
||||
nextGeneratedIndex: 1,
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
kind: 'audio',
|
||||
audioKind: 'sound-effect',
|
||||
normalizedPrompt: '游戏音效',
|
||||
input: {
|
||||
prompt: '游戏音效',
|
||||
model: 'audio1.0',
|
||||
duration: 5,
|
||||
},
|
||||
expect(automatic).toMatchObject({
|
||||
input: { duration: null, loop: true },
|
||||
result: {
|
||||
title: '游戏音效 6',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: 'prompt', value: '游戏音效' },
|
||||
{ title: 'model', value: 'audio1.0' },
|
||||
{ title: '时长', value: '5秒' },
|
||||
],
|
||||
references: [],
|
||||
fields: expect.arrayContaining([
|
||||
{ title: '请求时长', value: '自动' },
|
||||
{ title: 'Loop', value: '开启' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manual = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '精确时长音效',
|
||||
status: 'idle',
|
||||
soundDurationMode: 'manual',
|
||||
soundDurationSeconds: 1.23456789,
|
||||
soundLoop: false,
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 2,
|
||||
});
|
||||
expect(manual).toMatchObject({
|
||||
input: { duration: 1.23456789, loop: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an all-whitespace sound effect prompt without a fallback', () => {
|
||||
expect(() =>
|
||||
buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: ' \t\r\n ',
|
||||
status: 'idle',
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 6,
|
||||
}),
|
||||
).toThrow('音效描述不能为空');
|
||||
});
|
||||
|
||||
it('builds game background music audio submission plans with instrumental fixed to true', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EDITOR_SOUND_EFFECT_MODEL } from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import {
|
||||
CUSTOM_EDITOR_SCENE_STYLE_PRESET,
|
||||
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
|
||||
@@ -40,7 +41,6 @@ import {
|
||||
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
|
||||
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
DEFAULT_SPEC_FORM_VALUES,
|
||||
DEFAULT_VIDEO_ASPECT_RATIO,
|
||||
DEFAULT_VIDEO_DURATION_SECONDS,
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
SPEC_TYPE_LABEL,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
|
||||
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
|
||||
|
||||
type ImageGenerationSubmissionOptions = {
|
||||
dialog: GenerateDialogState;
|
||||
@@ -320,7 +321,21 @@ export function buildImageGenerationSubmissionPlan({
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedPrompt = resolveImageGenerationDialogPrompt(dialog);
|
||||
const soundEffectPrompt =
|
||||
dialog.mode === 'audio-sound-effect'
|
||||
? validateSoundEffectPrompt(dialog.prompt)
|
||||
: null;
|
||||
if (soundEffectPrompt && !soundEffectPrompt.ok) {
|
||||
throw new Error(
|
||||
soundEffectPrompt.reason === 'empty'
|
||||
? '音效描述不能为空'
|
||||
: '音效描述不能超过 2048 个字符',
|
||||
);
|
||||
}
|
||||
// SFX 走自己的 canonicalization 与 2048 边界;其余模式复用统一解析(含 scene 必填语义)。
|
||||
const normalizedPrompt = soundEffectPrompt
|
||||
? soundEffectPrompt.prompt
|
||||
: resolveImageGenerationDialogPrompt(dialog);
|
||||
|
||||
if (dialog.mode === 'edit') {
|
||||
const sourceLayer = layers.find(
|
||||
@@ -681,11 +696,14 @@ export function buildImageGenerationSubmissionPlan({
|
||||
}
|
||||
|
||||
if (dialog.mode === 'audio-sound-effect') {
|
||||
const soundModel = dialog.soundModel ?? DEFAULT_SOUND_EFFECT_MODEL;
|
||||
const soundModel = EDITOR_SOUND_EFFECT_MODEL;
|
||||
const durationSeconds =
|
||||
typeof dialog.soundDurationSeconds === 'number'
|
||||
? Math.min(10, Math.max(2, Math.round(dialog.soundDurationSeconds)))
|
||||
: DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
|
||||
dialog.soundDurationMode === 'auto'
|
||||
? null
|
||||
: typeof dialog.soundDurationSeconds === 'number'
|
||||
? dialog.soundDurationSeconds
|
||||
: DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
|
||||
const loop = dialog.soundLoop === true;
|
||||
return {
|
||||
kind: 'audio',
|
||||
audioKind: 'sound-effect',
|
||||
@@ -694,6 +712,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
prompt: normalizedPrompt,
|
||||
model: soundModel,
|
||||
duration: durationSeconds,
|
||||
loop,
|
||||
},
|
||||
result: {
|
||||
title: resolveGenerationAssetLabel(
|
||||
@@ -704,6 +723,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
normalizedPrompt,
|
||||
soundModel,
|
||||
durationSeconds,
|
||||
loop,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -190,6 +190,34 @@ describe('ImageCanvasLayerCommandModel', () => {
|
||||
expect(removeCanvasLayers(layers, ['first'])).toEqual([layers[1]]);
|
||||
});
|
||||
|
||||
it('deep-clones authoritative SFX metadata with generation inputs', () => {
|
||||
const layer = createLayer({
|
||||
id: 'sound-effect',
|
||||
generationInputs: {
|
||||
fields: [{ title: '用户描述', value: '金币落地' }],
|
||||
references: [],
|
||||
soundEffect: {
|
||||
schemaVersion: 2,
|
||||
userPrompt: '金币落地',
|
||||
actualPrompt: 'A coin landing sound',
|
||||
model: 'eleven_text_to_sound_v2',
|
||||
durationMode: 'auto',
|
||||
requestedDurationSeconds: null,
|
||||
actualDurationSeconds: 4.8,
|
||||
loop: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const clipboard = createCanvasLayerClipboard([layer], [layer.id], 'copy');
|
||||
const cloned = clipboard?.layers[0];
|
||||
|
||||
expect(cloned?.generationInputs).toEqual(layer.generationInputs);
|
||||
expect(cloned?.generationInputs).not.toBe(layer.generationInputs);
|
||||
expect(cloned?.generationInputs?.soundEffect).not.toBe(
|
||||
layer.generationInputs?.soundEffect,
|
||||
);
|
||||
});
|
||||
|
||||
it('moves layer z-indexes with the same commands as the context menu', () => {
|
||||
const layers = [
|
||||
createLayer({ id: 'bottom', zIndex: 1 }),
|
||||
|
||||
@@ -24,10 +24,14 @@ function cloneLayer(layer: CanvasLayer): CanvasLayer {
|
||||
...layer,
|
||||
generationInputs: layer.generationInputs
|
||||
? {
|
||||
...layer.generationInputs,
|
||||
fields: layer.generationInputs.fields.map((field) => ({ ...field })),
|
||||
references: layer.generationInputs.references.map((reference) => ({
|
||||
...reference,
|
||||
})),
|
||||
...(layer.generationInputs.soundEffect
|
||||
? { soundEffect: { ...layer.generationInputs.soundEffect } }
|
||||
: {}),
|
||||
}
|
||||
: layer.generationInputs,
|
||||
};
|
||||
|
||||
@@ -274,6 +274,61 @@ describe('ImageCanvasMetadataModalView', () => {
|
||||
expect(within(dialog).queryByText('420 x 120 px')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders SFX V2 authoritative prompts, actual duration, loop, model, and full task id', () => {
|
||||
render(
|
||||
<ImageCanvasMetadataModalView
|
||||
layer={createLayer({
|
||||
title: '金币音效',
|
||||
src: '/generated-character-drafts/editor-audios/coin.mp3',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
originalWidth: 420,
|
||||
originalHeight: 120,
|
||||
model: 'eleven_text_to_sound_v2',
|
||||
taskId: 'task-sfx-v2-1234-abcd',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '用户描述', value: '金币落地' },
|
||||
{
|
||||
title: '实际英文提示词',
|
||||
value: 'A bright coin landing chime',
|
||||
},
|
||||
{ title: '时长', value: '7.42秒' },
|
||||
{ title: 'Loop', value: '开启' },
|
||||
],
|
||||
references: [],
|
||||
soundEffect: {
|
||||
schemaVersion: 2,
|
||||
userPrompt: '金币落地',
|
||||
actualPrompt: 'A bright coin landing chime',
|
||||
model: 'eleven_text_to_sound_v2',
|
||||
durationMode: 'auto',
|
||||
requestedDurationSeconds: null,
|
||||
actualDurationSeconds: 7.42,
|
||||
loop: true,
|
||||
},
|
||||
},
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '音频信息' });
|
||||
for (const text of [
|
||||
'用户描述',
|
||||
'金币落地',
|
||||
'实际英文提示词',
|
||||
'A bright coin landing chime',
|
||||
'7.42秒',
|
||||
'Loop',
|
||||
'开启',
|
||||
'eleven_text_to_sound_v2',
|
||||
'task-sfx-v2-1234-abcd',
|
||||
]) {
|
||||
expect(within(dialog).getByText(text)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not render a dialog when no layer is selected', () => {
|
||||
render(<ImageCanvasMetadataModalView layer={null} onClose={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { formatTaskIdForDisplay } from './ImageCanvasExportModel';
|
||||
import {
|
||||
formatEditorGenerationInputFieldValue,
|
||||
formatLayerImageType,
|
||||
getEditorLayerModelDisplayName,
|
||||
isEditorUserVisibleGenerationInputField,
|
||||
@@ -63,7 +64,7 @@ export function ImageCanvasMetadataModalView({
|
||||
<span className="image-canvas-editor__metadata-input-title">
|
||||
{field.title}
|
||||
</span>
|
||||
<span>{field.value}</span>
|
||||
<span>{formatEditorGenerationInputFieldValue(field)}</span>
|
||||
</div>
|
||||
))}
|
||||
{generationInputReferences.length ? (
|
||||
@@ -105,7 +106,11 @@ export function ImageCanvasMetadataModalView({
|
||||
</>
|
||||
) : null}
|
||||
<dt>Task</dt>
|
||||
<dd>{formatTaskIdForDisplay(layer.taskId)}</dd>
|
||||
<dd>
|
||||
{layer.generationInputs?.soundEffect?.schemaVersion === 2
|
||||
? layer.taskId || '-'
|
||||
: formatTaskIdForDisplay(layer.taskId)}
|
||||
</dd>
|
||||
</dl>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* @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, preset controls and the track scroll 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);
|
||||
// 「优化中锁定滚动」按轨道自身不可被手动横向滚动验收,不只是停掉自动循环。
|
||||
expect(
|
||||
view.container.querySelector(
|
||||
'.image-canvas-editor__background-music-presets-viewport--locked',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
appendSoundEffectPromptPreset,
|
||||
SOUND_EFFECT_PROMPT_PRESET_GROUPS,
|
||||
SOUND_EFFECT_PROMPT_PRESETS,
|
||||
} from './ImageCanvasSoundEffectPresetModel';
|
||||
|
||||
describe('ImageCanvasSoundEffectPresetModel', () => {
|
||||
it('freezes 40 event presets and 12 requirements with unique identities', () => {
|
||||
expect(SOUND_EFFECT_PROMPT_PRESETS).toHaveLength(52);
|
||||
expect(
|
||||
SOUND_EFFECT_PROMPT_PRESETS.filter(
|
||||
(preset) => preset.category === 'event',
|
||||
),
|
||||
).toHaveLength(40);
|
||||
expect(
|
||||
SOUND_EFFECT_PROMPT_PRESETS.filter(
|
||||
(preset) => preset.category === 'requirement',
|
||||
),
|
||||
).toHaveLength(12);
|
||||
expect(new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).size).toBe(
|
||||
52,
|
||||
);
|
||||
expect(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).toEqual([
|
||||
'button-tap',
|
||||
'operation-confirm',
|
||||
'back-cancel',
|
||||
'page-transition',
|
||||
'notification',
|
||||
'operation-error',
|
||||
'coin-pickup',
|
||||
'item-pickup',
|
||||
'reward-received',
|
||||
'chest-open',
|
||||
'content-unlock',
|
||||
'rare-drop',
|
||||
'item-craft',
|
||||
'character-level-up',
|
||||
'quest-complete',
|
||||
'achievement-unlocked',
|
||||
'challenge-victory',
|
||||
'challenge-defeat',
|
||||
'character-jump',
|
||||
'character-land',
|
||||
'light-hit',
|
||||
'heavy-hit',
|
||||
'attack-swing',
|
||||
'attack-hit',
|
||||
'block-success',
|
||||
'object-break',
|
||||
'skill-charge',
|
||||
'magic-cast',
|
||||
'healing',
|
||||
'shield-create',
|
||||
'teleport',
|
||||
'ice-skill',
|
||||
'fire-skill',
|
||||
'status-buff',
|
||||
'door-open',
|
||||
'mechanism-start',
|
||||
'lever-trigger',
|
||||
'stone-move',
|
||||
'portal-open',
|
||||
'countdown-warning',
|
||||
'casual-cute',
|
||||
'retro-arcade',
|
||||
'sci-fi-electronic',
|
||||
'fantasy-magic',
|
||||
'realistic-natural',
|
||||
'cartoon-exaggerated',
|
||||
'gentle-feedback',
|
||||
'strong-feedback',
|
||||
'short-feedback',
|
||||
'two-stage-rise',
|
||||
'clean-prominent',
|
||||
'soft-not-harsh',
|
||||
]);
|
||||
expect(
|
||||
new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ label }) => label)).size,
|
||||
).toBe(52);
|
||||
expect(
|
||||
new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ group }) => group)),
|
||||
).toEqual(new Set(SOUND_EFFECT_PROMPT_PRESET_GROUPS));
|
||||
});
|
||||
|
||||
it('locks the visible labels and prompt text in authoritative order', () => {
|
||||
expect(
|
||||
SOUND_EFFECT_PROMPT_PRESETS.map(({ category, group, label, prompt }) => [
|
||||
category,
|
||||
group,
|
||||
label,
|
||||
prompt,
|
||||
]),
|
||||
).toEqual([
|
||||
['event', 'ui-operation', '轻触按钮', '柔和的按钮点击声'],
|
||||
['event', 'ui-operation', '确认操作', '明亮的确认提示音'],
|
||||
['event', 'ui-operation', '返回取消', '轻微下降的取消提示音'],
|
||||
['event', 'ui-operation', '页面切换', '快速掠过的界面切换声'],
|
||||
['event', 'ui-operation', '通知提醒', '清晰柔和的通知提示音'],
|
||||
['event', 'ui-operation', '操作错误', '短促克制的错误提示音'],
|
||||
['event', 'pickup-reward', '金币拾取', '金币拾取时清脆的金属叮当声'],
|
||||
['event', 'pickup-reward', '道具拾取', '拾取道具时轻快的提示音'],
|
||||
['event', 'pickup-reward', '获得奖励', '奖励出现时明亮的提示音'],
|
||||
[
|
||||
'event',
|
||||
'pickup-reward',
|
||||
'宝箱开启',
|
||||
'金属锁扣弹开,随后响起明亮的奖励提示音',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'pickup-reward',
|
||||
'解锁内容',
|
||||
'锁定状态解除,随后响起解锁提示音',
|
||||
],
|
||||
['event', 'pickup-reward', '稀有掉落', '稀有物品出现时闪耀的奖励提示音'],
|
||||
[
|
||||
'event',
|
||||
'growth-result',
|
||||
'物品合成',
|
||||
'两件物品融合,随后响起明亮的完成提示音',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'growth-result',
|
||||
'角色升级',
|
||||
'能量快速上升,随后响起明亮的升级提示音',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'growth-result',
|
||||
'任务完成',
|
||||
'任务完成提示音,随后响起简短的奖励音符',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'growth-result',
|
||||
'成就达成',
|
||||
'明亮的成就提示音,随后响起简短的庆祝音符',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'growth-result',
|
||||
'挑战胜利',
|
||||
'明亮的胜利提示音,随后响起短暂的庆祝音符',
|
||||
],
|
||||
['event', 'growth-result', '挑战失败', '低沉的失败提示音'],
|
||||
['event', 'character-combat', '角色跳跃', '角色轻盈跳起的声音'],
|
||||
['event', 'character-combat', '角色落地', '角色落地时轻微的撞击声'],
|
||||
['event', 'character-combat', '轻度受击', '轻微撞击的受击声'],
|
||||
['event', 'character-combat', '重度受击', '沉重有力的撞击声'],
|
||||
['event', 'character-combat', '攻击挥动', '武器快速挥过空气的呼啸声'],
|
||||
['event', 'character-combat', '攻击命中', '武器击中目标的清晰撞击声'],
|
||||
['event', 'character-combat', '格挡成功', '武器碰撞,随后被挡开的金属声'],
|
||||
['event', 'character-combat', '物体破碎', '物体撞击地面后快速破碎的声音'],
|
||||
['event', 'skill-status', '技能蓄力', '能量逐渐聚集的低沉嗡鸣声'],
|
||||
[
|
||||
'event',
|
||||
'skill-status',
|
||||
'魔法释放',
|
||||
'柔和的魔法能量扩散,带有圆润空灵的闪光声',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'skill-status',
|
||||
'治疗恢复',
|
||||
'柔和能量扩散,带有温暖圆润的提示音',
|
||||
],
|
||||
['event', 'skill-status', '护盾生成', '能量向外展开,形成稳定的护盾声'],
|
||||
[
|
||||
'event',
|
||||
'skill-status',
|
||||
'瞬间移动',
|
||||
'能量快速收缩,随后以短促的空气抽离声消失',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'skill-status',
|
||||
'冰冻技能',
|
||||
'冰霜能量扩散,随后响起清脆的冻结声',
|
||||
],
|
||||
['event', 'skill-status', '火焰技能', '火焰迅速喷发,带有短促的燃烧声'],
|
||||
[
|
||||
'event',
|
||||
'skill-status',
|
||||
'状态强化',
|
||||
'能量逐渐上升,形成稳定明亮的提示音',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'mechanism-scene-interaction',
|
||||
'门开启',
|
||||
'门锁解除,随后厚重的木门缓慢打开',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'mechanism-scene-interaction',
|
||||
'机关启动',
|
||||
'机关解锁,随后齿轮开始转动',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'mechanism-scene-interaction',
|
||||
'拉杆触发',
|
||||
'拉杆被扳动,随后远处机关启动',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'mechanism-scene-interaction',
|
||||
'石块移动',
|
||||
'大型石块缓慢移动时低沉的摩擦声',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'mechanism-scene-interaction',
|
||||
'传送门开启',
|
||||
'能量旋转聚集,随后响起持续、空灵的传送门展开声',
|
||||
],
|
||||
[
|
||||
'event',
|
||||
'mechanism-scene-interaction',
|
||||
'倒计时警告',
|
||||
'逐渐加快的倒计时提示音',
|
||||
],
|
||||
['requirement', 'style-direction', '休闲可爱', '轻快可爱的卡通风格'],
|
||||
['requirement', 'style-direction', '复古街机', '复古街机风格'],
|
||||
['requirement', 'style-direction', '科幻电子', '干净的科幻电子音色'],
|
||||
['requirement', 'style-direction', '奇幻魔法', '柔和梦幻的魔法音色'],
|
||||
['requirement', 'style-direction', '写实自然', '自然真实的声音质感'],
|
||||
['requirement', 'style-direction', '卡通夸张', '夸张鲜明的卡通风格'],
|
||||
['requirement', 'feedback-requirement', '轻柔反馈', '轻柔克制'],
|
||||
['requirement', 'feedback-requirement', '有力反馈', '更有力的撞击感'],
|
||||
['requirement', 'feedback-requirement', '短促反馈', '短促的单次声音'],
|
||||
['requirement', 'feedback-requirement', '两段递进', '由弱到强的两段变化'],
|
||||
[
|
||||
'requirement',
|
||||
'feedback-requirement',
|
||||
'干净突出',
|
||||
'主体声音清晰,减少杂音',
|
||||
],
|
||||
[
|
||||
'requirement',
|
||||
'feedback-requirement',
|
||||
'柔和不刺耳',
|
||||
'圆润柔和,避免尖锐高频',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('canonicalizes and appends with the frozen ECMAScript trim rule', () => {
|
||||
const preset = SOUND_EFFECT_PROMPT_PRESETS[40];
|
||||
expect(appendSoundEffectPromptPreset('\uFEFF\u2003', preset)).toBe(
|
||||
preset.prompt,
|
||||
);
|
||||
expect(appendSoundEffectPromptPreset('金币落地', preset)).toBe(
|
||||
`金币落地,${preset.prompt}`,
|
||||
);
|
||||
expect(appendSoundEffectPromptPreset('金币落地!\uFEFF', preset)).toBe(
|
||||
`金币落地!${preset.prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows duplicates and preserves overlong text without truncation', () => {
|
||||
const preset = SOUND_EFFECT_PROMPT_PRESETS[0];
|
||||
const once = appendSoundEffectPromptPreset('按钮', preset);
|
||||
expect(appendSoundEffectPromptPreset(once, preset)).toBe(
|
||||
`按钮,${preset.prompt},${preset.prompt}`,
|
||||
);
|
||||
|
||||
const overlong = '声'.repeat(2048);
|
||||
const appended = appendSoundEffectPromptPreset(overlong, preset);
|
||||
expect(appended).toBe(`${overlong},${preset.prompt}`);
|
||||
expect(Array.from(appended).length).toBeGreaterThan(2048);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user