合并远端主分支更新
Project CI / Repository checks (pull_request) Failing after 54s
Project CI / Frontend tests (pull_request) Successful in 3m30s
Project CI / Backend tests (pull_request) Successful in 4m21s
Project CI / Native shell tests (pull_request) Has been cancelled

同步主分支音效生成与External v1契约更新
保留图标规范与图集生成链路
适配VectorEngine LLM客户端命名并解决前端提交语义冲突
This commit is contained in:
2026-08-08 21:54:30 +08:00
103 changed files with 11863 additions and 1097 deletions
@@ -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();
+10 -1
View File
@@ -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