合并主分支

解决简单冲突
This commit is contained in:
2026-08-06 18:54:32 +08:00
55 changed files with 11232 additions and 270 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,480 @@
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';
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,
onSelectPreset,
headerTrailing,
}: {
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>
);
}
@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import {
appendBackgroundMusicPromptPreset,
BACKGROUND_MUSIC_PROMPT_PRESETS,
type BackgroundMusicPromptPreset,
} from './ImageCanvasBackgroundMusicPresetModel';
// 权威设计《画板音乐生成入口设计》「预设库与追加规则」的固定文案。
// 这张表就是转录校验本身,改动必须先改权威设计。
const AUTHORITATIVE_PRESETS: ReadonlyArray<
[BackgroundMusicPromptPreset['group'], string, string]
> = [
['purpose', '菜单待机', '低干扰、适合菜单待机的背景音乐'],
['purpose', '休闲消除', '轻快可爱的休闲消除背景音乐'],
['purpose', '解谜思考', '安静专注的解谜思考背景音乐'],
['purpose', '探索冒险', '温和推进的探索冒险背景音乐'],
['purpose', '对白场景', '克制柔和、留出对白空间的背景音乐'],
['purpose', '战斗前', '蓄势待发的战斗前背景音乐'],
['purpose', '胜利结算', '明亮满足的胜利结算背景音乐'],
['purpose', '失败结算', '克制低落的失败结算背景音乐'],
['purpose', '日常经营', '轻松有序的日常经营背景音乐'],
['purpose', '农场经营', '自然温暖的农场经营背景音乐'],
['purpose', '校园日常', '青春轻松的校园日常背景音乐'],
['purpose', '美食厨房', '温暖活泼的美食厨房背景音乐'],
['atmosphere', '温暖治愈', '柔和明亮的治愈背景音乐'],
['atmosphere', '神秘悬疑', '克制神秘的悬疑背景音乐'],
['atmosphere', '紧张推进', '稳定推进、逐渐紧张的背景音乐'],
['scene', '森林自然', '清新自然的森林背景音乐'],
['scene', '雨夜静谧', '雨夜静谧、略带神秘感的背景音乐'],
['scene', '海洋漂流', '开阔舒缓的海洋漂流背景音乐'],
['scene', '山野远行', '自由舒展的山野远行背景音乐'],
['scene', '糖果乐园', '甜美活泼的糖果乐园背景音乐'],
['scene', '温馨小屋', '温暖安静的温馨小屋背景音乐'],
['scene', '城市夜晚', '克制迷人的城市夜晚背景音乐'],
['scene', '太空科幻', '空灵未来感的太空科幻背景音乐'],
['scene', '赛博街区', '冷静律动的赛博街区背景音乐'],
['scene', '古风幻想', '空灵雅致的古风幻想背景音乐'],
['scene', '童话花园', '梦幻轻盈的童话花园背景音乐'],
['scene', '海底遗迹', '深邃神秘的海底遗迹背景音乐'],
['scene', '沙漠遗迹', '苍茫神秘的沙漠遗迹背景音乐'],
['scene', '熔岩洞穴', '炽热压迫的熔岩洞穴背景音乐'],
['scene', '奇妙博物馆', '好奇灵动的奇妙博物馆背景音乐'],
];
function presetByLabel(label: string) {
const preset = BACKGROUND_MUSIC_PROMPT_PRESETS.find(
(candidate) => candidate.label === label,
);
if (!preset) {
throw new Error(`missing background music preset: ${label}`);
}
return preset;
}
const explorationPreset = presetByLabel('探索冒险');
const healingPreset = presetByLabel('温暖治愈');
describe('ImageCanvasBackgroundMusicPresetModel', () => {
it('keeps the authoritative three groups at 12 / 3 / 15 and 30 total', () => {
const groupCounts = BACKGROUND_MUSIC_PROMPT_PRESETS.reduce<
Record<string, number>
>(
(counts, preset) => ({
...counts,
[preset.group]: (counts[preset.group] ?? 0) + 1,
}),
{},
);
expect(BACKGROUND_MUSIC_PROMPT_PRESETS).toHaveLength(30);
expect(groupCounts).toEqual({ purpose: 12, atmosphere: 3, scene: 15 });
});
it('copies every authoritative label and prompt without rewriting them', () => {
expect(
BACKGROUND_MUSIC_PROMPT_PRESETS.map((preset) => [
preset.group,
preset.label,
preset.prompt,
]),
).toEqual(AUTHORITATIVE_PRESETS);
});
it('exposes stable unique ids that never appear in the visible prompt', () => {
const ids = BACKGROUND_MUSIC_PROMPT_PRESETS.map((preset) => preset.id);
expect(new Set(ids).size).toBe(ids.length);
for (const preset of BACKGROUND_MUSIC_PROMPT_PRESETS) {
expect(preset.id).toMatch(/^[a-z][a-z0-9-]*$/u);
expect(preset.prompt).not.toContain(preset.id);
expect(preset.prompt).not.toContain(preset.group);
}
});
it('writes the preset text directly into an empty or all-whitespace prompt', () => {
// 不可见 code point 一律用显式转义:字面量在编辑器和 lint autofix 里容易被吃掉。
for (const currentPrompt of [
'',
' ',
'…',
' \t\n\r\n  

',
]) {
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(explorationPreset.prompt);
}
});
it('appends directly after Chinese, ASCII and other Unicode punctuation', () => {
for (const ending of [
'。',
'',
'、',
'',
'',
'',
'',
'”',
'',
'.',
',',
'!',
'?',
';',
':',
'-',
'_',
'(',
')',
'"',
"'",
'…',
'—',
'「',
'」',
'·',
]) {
const currentPrompt = `阳光动物园${ending}`;
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(`${currentPrompt}${explorationPreset.prompt}`);
}
});
it('inserts a Chinese period after non-punctuation, symbols, emoji and combining marks', () => {
// `+` `=` `$` 属于 Unicode 符号而不是标点,必须补句号;emoji 与组合字符
// 用于证明末位是按 code point 而不是 UTF-16 code unit 读取的。
for (const ending of [
'园',
'a',
'7',
'+',
'=',
'$',
'\u{1f3b5}',
'\u{1f469}\u{1f3a4}',
'á',
]) {
const currentPrompt = `阳光动物园${ending}`;
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(`${currentPrompt}${explorationPreset.prompt}`);
}
});
it('removes only boundary whitespace and keeps internal CR / LF / CRLF unchanged', () => {
const currentPrompt = ' 阳光动物园\n第二行\r\n第三行\r第四行  ';
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(`阳光动物园\n第二行\r\n第三行\r第四行。${explorationPreset.prompt}`);
});
it('keeps boundary zero-width code points because they are not Unicode White_Space', () => {
const currentPrompt = '​阳光动物园';
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(`${currentPrompt}${explorationPreset.prompt}`);
});
it('appends again on every repeated click of the same preset', () => {
const first = appendBackgroundMusicPromptPreset('', explorationPreset);
const second = appendBackgroundMusicPromptPreset(first, explorationPreset);
const third = appendBackgroundMusicPromptPreset(second, explorationPreset);
expect(second).toBe(`${first}${explorationPreset.prompt}`);
expect(third).toBe(`${second}${explorationPreset.prompt}`);
});
it('merges different presets in click order without deduplicating', () => {
const withExploration = appendBackgroundMusicPromptPreset(
'阳光动物园',
explorationPreset,
);
expect(
appendBackgroundMusicPromptPreset(withExploration, healingPreset),
).toBe(`阳光动物园。${explorationPreset.prompt}${healingPreset.prompt}`);
});
it('keeps the whole text after crossing the 200 and 2000 code point limits', () => {
for (const currentLength of [200, 2001]) {
const currentPrompt = 'A'.repeat(currentLength);
const appended = appendBackgroundMusicPromptPreset(
currentPrompt,
explorationPreset,
);
expect(appended.startsWith(currentPrompt)).toBe(true);
expect(appended).toBe(`${currentPrompt}${explorationPreset.prompt}`);
expect(Array.from(appended)).toHaveLength(
currentLength + 1 + Array.from(explorationPreset.prompt).length,
);
}
});
it('returns only the visible prompt, never the preset id or group metadata', () => {
const appended = appendBackgroundMusicPromptPreset(
'阳光动物园',
explorationPreset,
);
// chip 上的 label 可能本来就是预设正文的一部分,所以只断言结果逐字等于
// 「canonical + 分隔符 + preset.prompt」,并禁止隐藏元数据泄漏。
expect(typeof appended).toBe('string');
expect(appended).toBe(`阳光动物园。${explorationPreset.prompt}`);
expect(appended).not.toContain(explorationPreset.id);
expect(appended).not.toContain(explorationPreset.group);
});
});
@@ -0,0 +1,243 @@
import { canonicalizeBackgroundMusicPrompt } from './ImageCanvasBackgroundMusicPromptModel';
export type BackgroundMusicPromptPresetGroup =
| 'purpose'
| 'atmosphere'
| 'scene';
export type BackgroundMusicPromptPreset = {
id: string;
group: BackgroundMusicPromptPresetGroup;
label: string;
prompt: string;
};
const UNICODE_PUNCTUATION_CODE_POINT = /^\p{Punctuation}$/u;
const BACKGROUND_MUSIC_PRESET_SENTENCE_SEPARATOR = '。';
export const BACKGROUND_MUSIC_PROMPT_PRESET_GROUPS = [
'purpose',
'atmosphere',
'scene',
] as const satisfies readonly BackgroundMusicPromptPresetGroup[];
/**
* 权威设计《画板音乐生成入口设计》「预设库与追加规则」的固定文案,逐项复制,不改写。
*
* `id`、`group` 只用于滚动轨道的颜色分组和 React key`label` 只用于 chip 展示;
* 写入输入框的永远只有 `prompt`,隐藏元数据不得进入 Prompt 或 Suno 请求。
*/
export const BACKGROUND_MUSIC_PROMPT_PRESETS = [
{
id: 'menu-idle',
group: 'purpose',
label: '菜单待机',
prompt: '低干扰、适合菜单待机的背景音乐',
},
{
id: 'casual-match',
group: 'purpose',
label: '休闲消除',
prompt: '轻快可爱的休闲消除背景音乐',
},
{
id: 'puzzle-thinking',
group: 'purpose',
label: '解谜思考',
prompt: '安静专注的解谜思考背景音乐',
},
{
id: 'exploration-adventure',
group: 'purpose',
label: '探索冒险',
prompt: '温和推进的探索冒险背景音乐',
},
{
id: 'dialogue-scene',
group: 'purpose',
label: '对白场景',
prompt: '克制柔和、留出对白空间的背景音乐',
},
{
id: 'pre-battle',
group: 'purpose',
label: '战斗前',
prompt: '蓄势待发的战斗前背景音乐',
},
{
id: 'victory-settlement',
group: 'purpose',
label: '胜利结算',
prompt: '明亮满足的胜利结算背景音乐',
},
{
id: 'defeat-settlement',
group: 'purpose',
label: '失败结算',
prompt: '克制低落的失败结算背景音乐',
},
{
id: 'daily-management',
group: 'purpose',
label: '日常经营',
prompt: '轻松有序的日常经营背景音乐',
},
{
id: 'farm-management',
group: 'purpose',
label: '农场经营',
prompt: '自然温暖的农场经营背景音乐',
},
{
id: 'campus-daily',
group: 'purpose',
label: '校园日常',
prompt: '青春轻松的校园日常背景音乐',
},
{
id: 'food-kitchen',
group: 'purpose',
label: '美食厨房',
prompt: '温暖活泼的美食厨房背景音乐',
},
{
id: 'warm-healing',
group: 'atmosphere',
label: '温暖治愈',
prompt: '柔和明亮的治愈背景音乐',
},
{
id: 'mystery-suspense',
group: 'atmosphere',
label: '神秘悬疑',
prompt: '克制神秘的悬疑背景音乐',
},
{
id: 'tension-buildup',
group: 'atmosphere',
label: '紧张推进',
prompt: '稳定推进、逐渐紧张的背景音乐',
},
{
id: 'forest-nature',
group: 'scene',
label: '森林自然',
prompt: '清新自然的森林背景音乐',
},
{
id: 'rainy-night',
group: 'scene',
label: '雨夜静谧',
prompt: '雨夜静谧、略带神秘感的背景音乐',
},
{
id: 'ocean-drift',
group: 'scene',
label: '海洋漂流',
prompt: '开阔舒缓的海洋漂流背景音乐',
},
{
id: 'mountain-journey',
group: 'scene',
label: '山野远行',
prompt: '自由舒展的山野远行背景音乐',
},
{
id: 'candy-park',
group: 'scene',
label: '糖果乐园',
prompt: '甜美活泼的糖果乐园背景音乐',
},
{
id: 'cozy-cabin',
group: 'scene',
label: '温馨小屋',
prompt: '温暖安静的温馨小屋背景音乐',
},
{
id: 'city-night',
group: 'scene',
label: '城市夜晚',
prompt: '克制迷人的城市夜晚背景音乐',
},
{
id: 'space-scifi',
group: 'scene',
label: '太空科幻',
prompt: '空灵未来感的太空科幻背景音乐',
},
{
id: 'cyber-block',
group: 'scene',
label: '赛博街区',
prompt: '冷静律动的赛博街区背景音乐',
},
{
id: 'ancient-fantasy',
group: 'scene',
label: '古风幻想',
prompt: '空灵雅致的古风幻想背景音乐',
},
{
id: 'fairytale-garden',
group: 'scene',
label: '童话花园',
prompt: '梦幻轻盈的童话花园背景音乐',
},
{
id: 'undersea-ruins',
group: 'scene',
label: '海底遗迹',
prompt: '深邃神秘的海底遗迹背景音乐',
},
{
id: 'desert-ruins',
group: 'scene',
label: '沙漠遗迹',
prompt: '苍茫神秘的沙漠遗迹背景音乐',
},
{
id: 'lava-cave',
group: 'scene',
label: '熔岩洞穴',
prompt: '炽热压迫的熔岩洞穴背景音乐',
},
{
id: 'curious-museum',
group: 'scene',
label: '奇妙博物馆',
prompt: '好奇灵动的奇妙博物馆背景音乐',
},
] as const satisfies readonly BackgroundMusicPromptPreset[];
function readLastCodePoint(value: string) {
// 必须按 code point 读取:直接取最后一个 UTF-16 code unit 会在 emoji 等
// surrogate pair 结尾时拿到半个字符,标点判断随之失真。
const codePoints = Array.from(value);
return codePoints[codePoints.length - 1];
}
/**
* 按权威设计的追加规则返回新的可见 Prompt。
*
* 输入先复用共享 canonicalizer(不使用会额外删除 U+FEFF 的原生 `trim()`),因此调用方
* 传入尚未规范化的文本也不会产出 ` 。预设文本` 这类结果;canonicalization 幂等,
* 已经写回过的文本再算一次结果相同。
*
* 不去重、不截断、不改内部空格与换行;追加后允许超过 200 或 2000 字,文本必须完整保留。
*/
export function appendBackgroundMusicPromptPreset(
currentPrompt: string,
preset: BackgroundMusicPromptPreset,
) {
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(currentPrompt);
if (!canonicalPrompt) {
return preset.prompt;
}
const lastCodePoint = readLastCodePoint(canonicalPrompt);
if (lastCodePoint && UNICODE_PUNCTUATION_CODE_POINT.test(lastCodePoint)) {
return `${canonicalPrompt}${preset.prompt}`;
}
return `${canonicalPrompt}${BACKGROUND_MUSIC_PRESET_SENTENCE_SEPARATOR}${preset.prompt}`;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
import type {
CanvasGenerationDialogState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
export const BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS = 200;
export const BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS =
BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS * 10;
export const BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS = 1;
export const BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS = 2;
const UNICODE_WHITE_SPACE_CODE_POINT = /^\p{White_Space}$/u;
const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u;
const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u;
const BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX =
'background-music-prompt-operation';
export type BackgroundMusicPromptOperationStatus =
| 'completing'
| 'simplifying'
| 'submitting';
export type BackgroundMusicPromptDialogStatus =
| 'idle'
| BackgroundMusicPromptOperationStatus;
export type BackgroundMusicPromptOperation = {
dialogId: string;
operationId: string;
status: BackgroundMusicPromptOperationStatus;
canonicalPrompt: string;
};
export type BackgroundMusicPromptDialogState = {
dialogId: string;
status: BackgroundMusicPromptDialogStatus;
operationId: string | null;
undoPromptSnapshot: string | null;
temporaryPromptSnapshot: string | null;
};
export type BackgroundMusicPromptOperationStartResult = {
started: boolean;
reason: 'started' | 'duplicate' | 'ineligible' | 'locked';
canonicalPrompt: string;
operation: BackgroundMusicPromptOperation | null;
state: BackgroundMusicPromptDialogState;
};
export type BackgroundMusicPromptOperationResolution = {
applied: boolean;
prompt: string | null;
state: BackgroundMusicPromptDialogState;
};
export type BackgroundMusicPromptBoundaryResult = {
applied: boolean;
prompt: string;
state: BackgroundMusicPromptDialogState;
};
type InternalBackgroundMusicPromptDialogState =
BackgroundMusicPromptDialogState & {
activeCanonicalPrompt: string | null;
};
export type BackgroundMusicPromptStateModel = ReturnType<
typeof createBackgroundMusicPromptStateModel
>;
export type BackgroundMusicGenerationDialogState =
CanvasGenerationDialogState & {
mode: 'audio-background-music';
};
/**
* `GenerateDialogState` 是单一对象类型而不是可判别联合,`mode === 'audio-background-music'`
* 只窄化属性读取,展开后 `mode` 与可选 `id` 仍是宽类型。助手动作全部按 dialog ID 生效,
* 所以消费前必须先收窄出带稳定 ID 的 BGM dialog,不能把可选 `id` 直接传下去。
*/
export function toBackgroundMusicGenerationDialog(
dialog: GenerateDialogState | null,
): BackgroundMusicGenerationDialogState | null {
if (!dialog || dialog.mode !== 'audio-background-music' || !dialog.id) {
return null;
}
return { ...dialog, id: dialog.id, mode: 'audio-background-music' };
}
export function canonicalizeBackgroundMusicPrompt(prompt: string) {
return prompt
.replace(LEADING_UNICODE_WHITE_SPACE, '')
.replace(TRAILING_UNICODE_WHITE_SPACE, '');
}
export function countPromptCodePoints(prompt: string) {
return Array.from(canonicalizeBackgroundMusicPrompt(prompt)).length;
}
export function countEffectivePromptCodePoints(prompt: string) {
let effectiveCodePoints = 0;
for (const codePoint of canonicalizeBackgroundMusicPrompt(prompt)) {
if (!UNICODE_WHITE_SPACE_CODE_POINT.test(codePoint)) {
effectiveCodePoints += 1;
}
}
return effectiveCodePoints;
}
export function canGenerateBackgroundMusicFromPrompt(prompt: string) {
return (
countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
countEffectivePromptCodePoints(prompt) >=
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS
);
}
export function canCompleteBackgroundMusicPrompt(prompt: string) {
return (
countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
countEffectivePromptCodePoints(prompt) >=
BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS
);
}
export function canSimplifyBackgroundMusicPrompt(prompt: string) {
const codePointCount = countPromptCodePoints(prompt);
return (
codePointCount > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
codePointCount <= BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS &&
countEffectivePromptCodePoints(prompt) >=
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS
);
}
function createIdleBackgroundMusicPromptDialogState(
dialogId: string,
): InternalBackgroundMusicPromptDialogState {
return {
dialogId,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
}
function toPublicBackgroundMusicPromptDialogState(
state: InternalBackgroundMusicPromptDialogState,
): BackgroundMusicPromptDialogState {
return {
dialogId: state.dialogId,
status: state.status,
operationId: state.operationId,
undoPromptSnapshot: state.undoPromptSnapshot,
temporaryPromptSnapshot: state.temporaryPromptSnapshot,
};
}
function canStartBackgroundMusicPromptOperation(
status: BackgroundMusicPromptOperationStatus,
canonicalPrompt: string,
) {
if (status === 'completing') {
return canCompleteBackgroundMusicPrompt(canonicalPrompt);
}
if (status === 'simplifying') {
return canSimplifyBackgroundMusicPrompt(canonicalPrompt);
}
return canGenerateBackgroundMusicFromPrompt(canonicalPrompt);
}
/**
* Keeps dialog-scoped Prompt workflow state outside React. All operation claims
* happen synchronously, so callers can store one model instance in a ref and
* decide whether to issue a request before their first await.
*/
export function createBackgroundMusicPromptStateModel() {
const dialogStates = new Map<
string,
InternalBackgroundMusicPromptDialogState
>();
let operationSequence = 0;
const readInternalState = (dialogId: string) =>
dialogStates.get(dialogId) ??
createIdleBackgroundMusicPromptDialogState(dialogId);
const saveState = (state: InternalBackgroundMusicPromptDialogState) => {
dialogStates.set(state.dialogId, state);
return toPublicBackgroundMusicPromptDialogState(state);
};
const getDialogState = (dialogId: string): BackgroundMusicPromptDialogState =>
toPublicBackgroundMusicPromptDialogState(readInternalState(dialogId));
const beginOperation = ({
dialogId,
status,
prompt,
}: {
dialogId: string;
status: BackgroundMusicPromptOperationStatus;
prompt: string;
}): BackgroundMusicPromptOperationStartResult => {
const currentState = readInternalState(dialogId);
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
if (currentState.status === status && currentState.operationId !== null) {
return {
started: false,
reason: 'duplicate',
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
operation: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
if (currentState.status === 'submitting') {
return {
started: false,
reason: 'locked',
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
operation: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
if (!canStartBackgroundMusicPromptOperation(status, canonicalPrompt)) {
return {
started: false,
reason: 'ineligible',
canonicalPrompt,
operation: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
operationSequence += 1;
const operation: BackgroundMusicPromptOperation = {
dialogId,
operationId: `${BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX}-${operationSequence}`,
status,
canonicalPrompt,
};
const isAiOperation = status === 'completing' || status === 'simplifying';
const nextState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status,
operationId: operation.operationId,
undoPromptSnapshot: isAiOperation
? null
: currentState.undoPromptSnapshot,
temporaryPromptSnapshot: isAiOperation ? canonicalPrompt : null,
activeCanonicalPrompt: canonicalPrompt,
};
return {
started: true,
reason: 'started',
canonicalPrompt,
operation,
state: saveState(nextState),
};
};
const isCurrentOperation = (
operation: BackgroundMusicPromptOperation,
state: InternalBackgroundMusicPromptDialogState,
) =>
state.dialogId === operation.dialogId &&
state.operationId === operation.operationId &&
state.status === operation.status;
const resolveAiOperation = (
operation: BackgroundMusicPromptOperation,
prompt: string,
): BackgroundMusicPromptOperationResolution => {
const currentState = readInternalState(operation.dialogId);
if (
operation.status === 'submitting' ||
!isCurrentOperation(operation, currentState)
) {
return {
applied: false,
prompt: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
if (!canGenerateBackgroundMusicFromPrompt(canonicalPrompt)) {
const failedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: false,
prompt: null,
state: saveState(failedState),
};
}
const completedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: currentState.temporaryPromptSnapshot,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: canonicalPrompt,
state: saveState(completedState),
};
};
const rejectOperation = (
operation: BackgroundMusicPromptOperation,
): BackgroundMusicPromptOperationResolution => {
const currentState = readInternalState(operation.dialogId);
if (!isCurrentOperation(operation, currentState)) {
return {
applied: false,
prompt: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const isAiOperation = operation.status !== 'submitting';
const failedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: isAiOperation
? null
: currentState.undoPromptSnapshot,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: currentState.activeCanonicalPrompt,
state: saveState(failedState),
};
};
const completeSubmittingOperation = (
operation: BackgroundMusicPromptOperation,
): BackgroundMusicPromptOperationResolution => {
const currentState = readInternalState(operation.dialogId);
if (
operation.status !== 'submitting' ||
!isCurrentOperation(operation, currentState)
) {
return {
applied: false,
prompt: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const submittedPrompt =
currentState.activeCanonicalPrompt ?? operation.canonicalPrompt;
const completedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: submittedPrompt,
state: saveState(completedState),
};
};
const preparePreset = (
dialogId: string,
prompt: string,
): BackgroundMusicPromptBoundaryResult => {
const currentState = readInternalState(dialogId);
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
if (currentState.status === 'submitting') {
return {
applied: false,
prompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const nextState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: canonicalPrompt,
state: saveState(nextState),
};
};
const swapUndoSnapshot = (
dialogId: string,
prompt: string,
): BackgroundMusicPromptBoundaryResult => {
const currentState = readInternalState(dialogId);
if (
currentState.status !== 'idle' ||
currentState.undoPromptSnapshot === null
) {
return {
applied: false,
prompt,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
const nextPrompt = currentState.undoPromptSnapshot;
const nextState: InternalBackgroundMusicPromptDialogState = {
...currentState,
undoPromptSnapshot: canonicalPrompt,
};
return {
applied: true,
prompt: nextPrompt,
state: saveState(nextState),
};
};
const closeDialog = (dialogId: string) => {
dialogStates.delete(dialogId);
};
const reset = () => {
dialogStates.clear();
};
return {
getDialogState,
beginOperation,
resolveAiOperation,
rejectOperation,
completeSubmittingOperation,
preparePreset,
swapUndoSnapshot,
closeDialog,
reset,
};
}
@@ -780,6 +780,7 @@ export function ImageCanvasEditorView({
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
@@ -1453,6 +1454,7 @@ export function ImageCanvasEditorView({
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1136,10 +1136,12 @@ export function buildBackgroundMusicGenerationInputs(
gptDescriptionPrompt: string,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField(
'gpt_description_prompt',
gptDescriptionPrompt,
),
fields: [
{
title: 'gpt_description_prompt',
value: gptDescriptionPrompt,
},
],
references: [],
};
}
@@ -1098,32 +1098,35 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('uses 5 seconds as default game sound effect duration', () => {
it('uses the game sound effect fallback for an all-whitespace prompt', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: '按钮确认短促音',
prompt: ' \t\r\n ',
status: 'idle',
},
layers: [],
nextGeneratedIndex: 6,
});
expect(plan).toMatchObject({
expect(plan).toEqual({
kind: 'audio',
audioKind: 'sound-effect',
normalizedPrompt: '游戏音效',
input: {
prompt: '按钮确认短促音',
prompt: '游戏音效',
model: 'audio1.0',
duration: 5,
},
result: {
title: '游戏音效 6',
generationInputs: {
fields: [
{ title: 'prompt', value: '按钮确认短促音' },
{ title: 'prompt', value: '游戏音效' },
{ title: 'model', value: 'audio1.0' },
{ title: '时长', value: '5秒' },
],
references: [],
},
},
});
@@ -1133,19 +1136,20 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-background-music',
prompt: ' 温暖轻快的森林冒险背景音乐 ',
prompt: '不会进入正式请求的旧值',
status: 'idle',
},
layers: [],
nextGeneratedIndex: 5,
canonicalBackgroundMusicPrompt: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF',
});
expect(plan).toEqual({
kind: 'audio',
audioKind: 'background-music',
normalizedPrompt: '温暖轻快的森林冒险背景音乐',
normalizedPrompt: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF',
input: {
gptDescriptionPrompt: '温暖轻快的森林冒险背景音乐',
gptDescriptionPrompt: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF',
makeInstrumental: true,
},
result: {
@@ -1154,7 +1158,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
fields: [
{
title: 'gpt_description_prompt',
value: '温暖轻快的森林冒险背景音乐',
value: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF',
},
],
references: [],
@@ -1162,4 +1166,18 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
},
});
});
it('does not build a background music plan without a claimed canonical prompt', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-background-music',
prompt: ' 不应原生 trim 或回退默认值 ',
status: 'idle',
},
layers: [],
nextGeneratedIndex: 1,
}),
).toThrow('背景音乐提交缺少已确认的提示词');
});
});
@@ -57,6 +57,7 @@ type ImageGenerationSubmissionOptions = {
dialog: GenerateDialogState;
layers: CanvasLayer[];
nextGeneratedIndex: number;
canonicalBackgroundMusicPrompt?: string;
};
export const EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS = 80;
@@ -158,9 +159,6 @@ function getDialogDefaultPrompt(mode: GenerateDialogState['mode']) {
if (mode === 'audio-sound-effect') {
return '游戏音效';
}
if (mode === 'audio-background-music') {
return '游戏背景音乐';
}
return 'AI 生成图片';
}
@@ -259,7 +257,32 @@ export function buildImageGenerationSubmissionPlan({
dialog,
layers,
nextGeneratedIndex,
canonicalBackgroundMusicPrompt,
}: ImageGenerationSubmissionOptions): ImageGenerationSubmissionPlan {
if (dialog.mode === 'audio-background-music') {
if (canonicalBackgroundMusicPrompt === undefined) {
throw new Error('背景音乐提交缺少已确认的提示词');
}
return {
kind: 'audio',
audioKind: 'background-music',
normalizedPrompt: canonicalBackgroundMusicPrompt,
input: {
gptDescriptionPrompt: canonicalBackgroundMusicPrompt,
makeInstrumental: true,
},
result: {
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`游戏背景音乐 ${nextGeneratedIndex}`,
),
generationInputs: buildBackgroundMusicGenerationInputs(
canonicalBackgroundMusicPrompt,
),
},
};
}
const normalizedPrompt =
dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode);
@@ -598,26 +621,6 @@ export function buildImageGenerationSubmissionPlan({
};
}
if (dialog.mode === 'audio-background-music') {
return {
kind: 'audio',
audioKind: 'background-music',
normalizedPrompt,
input: {
gptDescriptionPrompt: normalizedPrompt,
makeInstrumental: true,
},
result: {
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`游戏背景音乐 ${nextGeneratedIndex}`,
),
generationInputs:
buildBackgroundMusicGenerationInputs(normalizedPrompt),
},
};
}
const imageModel = normalizeEditorImageModel(dialog.imageModel);
return {
kind: 'image',
@@ -398,6 +398,72 @@ describe('useCanvasGenerationDialogs', () => {
]);
});
it('reads the latest active and archived dialog synchronously by id', () => {
const { result } = renderHook(() => useCanvasGenerationDialogs());
let firstDialogId = '';
let secondDialogId = '';
let activeDialogRead: CanvasGenerationDialogState | undefined;
let archivedDialogRead: CanvasGenerationDialogState | undefined;
act(() => {
firstDialogId = result.current.openCanvasGenerationDialog(
createDialog('audio-background-music', 'first'),
);
activeDialogRead =
result.current.getCanvasGenerationDialogById(firstDialogId);
secondDialogId = result.current.openCanvasGenerationDialog(
createDialog('audio-background-music', 'second'),
);
archivedDialogRead =
result.current.getCanvasGenerationDialogById(firstDialogId);
});
expect(activeDialogRead).toEqual(
expect.objectContaining({
id: firstDialogId,
prompt: 'first',
composerOpen: true,
}),
);
expect(archivedDialogRead).toEqual(
expect.objectContaining({
id: firstDialogId,
prompt: 'first',
composerOpen: false,
}),
);
expect(result.current.getCanvasGenerationDialogById(secondDialogId)).toEqual(
expect.objectContaining({
prompt: 'second',
composerOpen: true,
}),
);
let updatedDialogRead: CanvasGenerationDialogState | undefined;
act(() => {
result.current.updateCanvasGenerationDialogById(
firstDialogId,
(dialog) => ({
...dialog,
prompt: 'updated first',
}),
);
updatedDialogRead =
result.current.getCanvasGenerationDialogById(firstDialogId);
result.current.removeCanvasGenerationDialogById(firstDialogId);
});
expect(updatedDialogRead).toEqual(
expect.objectContaining({
prompt: 'updated first',
composerOpen: false,
}),
);
expect(
result.current.getCanvasGenerationDialogById(firstDialogId),
).toBeUndefined();
});
it('returns a newly opened explicit dialog from the synchronous snapshot getter in the same action', () => {
const { result } = renderHook(() => useCanvasGenerationDialogs());
let openedDialogId = '';
@@ -301,6 +301,19 @@ export function useCanvasGenerationDialogs({
].some((dialog) => dialog.id === dialogId);
}, []);
const getCanvasGenerationDialogById = useCallback((dialogId: string) => {
const currentDialog = generateDialogRef.current;
if (
isCanvasGenerationDialog(currentDialog) &&
currentDialog.id === dialogId
) {
return currentDialog;
}
return inactiveGenerateDialogsRef.current.find(
(dialog) => dialog.id === dialogId,
);
}, []);
const activateCanvasGenerationDialog = useCallback(
(targetDialog: CanvasGenerationDialogState) => {
const currentDialog = generateDialogRef.current;
@@ -439,6 +452,7 @@ export function useCanvasGenerationDialogs({
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,9 @@ import {
type MutableRefObject,
type SetStateAction,
useCallback,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration';
@@ -89,6 +91,7 @@ import type {
} from './ImageCanvasUiAssetExtractionModel';
import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel';
import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel';
import type { BackgroundMusicPromptAssistController } from './useImageCanvasBackgroundMusicPromptAssist';
type CanvasSize = { width: number; height: number };
@@ -323,6 +326,11 @@ type GenerationSubmissionWorkflowOptions = {
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
getCanvasGenerationDialogById: (
dialogId: string,
) => CanvasGenerationDialogState | undefined;
activeCanvasGenerationDialogId?: string | null;
backgroundMusicPromptAssist: BackgroundMusicPromptAssistController;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
@@ -342,6 +350,7 @@ type GenerationSubmissionWorkflowOptions = {
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
rememberImageModel: (imageModel: string) => void;
projectId?: string | null;
currentUserId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
@@ -633,6 +642,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
activeCanvasGenerationDialogId,
backgroundMusicPromptAssist,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
@@ -643,6 +655,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveSidebarPanel,
rememberImageModel,
projectId,
currentUserId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
@@ -650,6 +663,76 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onWalletBalanceMayHaveChanged,
onGenerationWarning,
}: GenerationSubmissionWorkflowOptions) {
const currentBackgroundMusicSubmissionScopeRef = useRef({
currentUserId: currentUserId ?? null,
projectId: projectId ?? null,
activeCanvasGenerationDialogId,
version: 0,
mounted: false,
});
useLayoutEffect(() => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
const scopeChanged =
!Object.is(currentScope.currentUserId, currentUserId ?? null) ||
!Object.is(currentScope.projectId, projectId ?? null);
currentBackgroundMusicSubmissionScopeRef.current = {
currentUserId: currentUserId ?? null,
projectId: projectId ?? null,
activeCanvasGenerationDialogId,
version: scopeChanged ? currentScope.version + 1 : currentScope.version,
mounted: true,
};
return () => {
currentBackgroundMusicSubmissionScopeRef.current.mounted = false;
};
}, [activeCanvasGenerationDialogId, currentUserId, projectId]);
const isBackgroundMusicSubmissionUiTargetCurrent = useCallback(
(scope: {
currentUserId: string | null;
projectId: string | null;
dialogId: string;
scopeVersion: number;
}) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(scope.dialogId);
return (
currentScope.mounted &&
currentScope.version === scope.scopeVersion &&
Object.is(scope.currentUserId, currentScope.currentUserId) &&
Object.is(scope.projectId, currentScope.projectId) &&
currentDialog?.mode === 'audio-background-music'
);
},
[getCanvasGenerationDialogById],
);
const isBackgroundMusicSubmissionAccountCurrent = useCallback(
(scope: { currentUserId: string | null }) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
return (
currentScope.mounted &&
Object.is(scope.currentUserId, currentScope.currentUserId)
);
},
[],
);
const isBackgroundMusicSubmissionProjectCurrent = useCallback(
(scope: {
currentUserId: string | null;
projectId: string | null;
}) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
return (
currentScope.mounted &&
Object.is(scope.currentUserId, currentScope.currentUserId) &&
Object.is(scope.projectId, currentScope.projectId)
);
},
[],
);
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
if (!nextLayers.length) {
@@ -1795,24 +1878,46 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const submitImageGeneration = useCallback(
async (dialog: GenerateDialogState) => {
const normalizedPrompt =
dialog.prompt.trim() ||
(dialog.mode === 'edit'
? '修改当前图片'
: dialog.mode === 'audio-sound-effect'
? '游戏音效'
: dialog.mode === 'audio-background-music'
? '游戏背景音乐'
: 'AI 生成图片');
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
if (canvasDialog) {
const backgroundMusicDialog =
canvasDialog?.mode === 'audio-background-music' ? canvasDialog : null;
if (dialog.mode === 'audio-background-music' && !backgroundMusicDialog) {
return;
}
const backgroundMusicClaim = backgroundMusicDialog
? backgroundMusicPromptAssist.beginSubmission(backgroundMusicDialog.id)
: null;
if (backgroundMusicDialog && !backgroundMusicClaim) {
return;
}
const backgroundMusicSubmission =
backgroundMusicDialog && backgroundMusicClaim
? {
currentUserId: currentUserId ?? null,
projectId: projectId ?? null,
dialogId: backgroundMusicDialog.id,
operation: backgroundMusicClaim.operation,
prompt: backgroundMusicClaim.prompt,
scopeVersion:
currentBackgroundMusicSubmissionScopeRef.current.version,
}
: null;
const normalizedPrompt =
backgroundMusicSubmission?.prompt ??
(dialog.prompt.trim() ||
(dialog.mode === 'edit'
? '修改当前图片'
: dialog.mode === 'audio-sound-effect'
? '游戏音效'
: 'AI 生成图片'));
if (!backgroundMusicSubmission && canvasDialog) {
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
...currentDialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: false,
}));
} else {
} else if (!backgroundMusicSubmission) {
setGenerateDialog({
...dialog,
prompt: normalizedPrompt,
@@ -1821,11 +1926,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
});
}
let backgroundMusicSubmissionAccepted = false;
let backgroundMusicSubmissionUiOwned = false;
let backgroundMusicQueueAcceptedWhileCurrentAndOpen = false;
try {
const submissionPlan = buildImageGenerationSubmissionPlan({
dialog,
layers,
nextGeneratedIndex: layerCounterRef.current + 1,
canonicalBackgroundMusicPrompt: backgroundMusicSubmission?.prompt,
});
// TODO legacy code
if (submissionPlan.kind === 'edit') {
@@ -2077,9 +2186,28 @@ export function useImageCanvasGenerationSubmissionWorkflow({
} else if (submissionPlan.kind === 'audio') {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
const generated =
submissionPlan.audioKind === 'sound-effect'
? generateEditorSoundEffect({
? await runEditorGenerationWithWalletRefresh(
generateEditorSoundEffect({
...submissionPlan.input,
projectId,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
)
: await generateEditorBackgroundMusic({
...submissionPlan.input,
projectId,
generationInputs: submissionPlan.result.generationInputs,
@@ -2094,36 +2222,110 @@ export function useImageCanvasGenerationSubmissionWorkflow({
},
}
: {}),
})
: generateEditorBackgroundMusic({
...submissionPlan.input,
projectId,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
...(projectId && canvasCompletionPlaceholder
});
const isBackgroundMusic =
submissionPlan.audioKind === 'background-music';
const backgroundMusicQueueState = isBackgroundMusic
? queuedStateFromResponse(generated)
: null;
if (isBackgroundMusic && backgroundMusicSubmission) {
backgroundMusicSubmissionAccepted = true;
const currentScope =
currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(
backgroundMusicSubmission.dialogId,
);
backgroundMusicQueueAcceptedWhileCurrentAndOpen =
Boolean(backgroundMusicQueueState) &&
currentScope.activeCanvasGenerationDialogId ===
backgroundMusicSubmission.dialogId &&
currentDialog?.composerOpen !== false;
backgroundMusicSubmissionUiOwned =
backgroundMusicPromptAssist.finishSubmission({
operation: backgroundMusicSubmission.operation,
accepted: true,
});
if (
backgroundMusicQueueState &&
backgroundMusicSubmissionUiOwned &&
isBackgroundMusicSubmissionUiTargetCurrent(
backgroundMusicSubmission,
)
) {
updateCanvasGenerationDialogById(
backgroundMusicSubmission.dialogId,
(currentDialog) =>
currentDialog.mode === 'audio-background-music'
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder: canvasCompletionPlaceholder,
},
...currentDialog,
prompt: backgroundMusicSubmission.prompt,
status: 'generating',
composerOpen: false,
errorMessage: undefined,
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
: currentDialog,
);
}
if (
!backgroundMusicQueueState &&
isBackgroundMusicSubmissionAccountCurrent(
backgroundMusicSubmission,
)
) {
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
}
}
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
backgroundMusicSubmission && applyProjectSnapshot
? (projectSnapshot) => {
if (
backgroundMusicSubmissionUiOwned &&
isBackgroundMusicSubmissionUiTargetCurrent(
backgroundMusicSubmission,
)
) {
applyProjectSnapshot(projectSnapshot);
}
}
: applyProjectSnapshot,
backgroundMusicSubmission && onQueuedGenerationTask
? () => {
if (
isBackgroundMusicSubmissionProjectCurrent(
backgroundMusicSubmission,
)
) {
onQueuedGenerationTask();
}
}
: onQueuedGenerationTask,
backgroundMusicSubmission && onWalletBalanceMayHaveChanged
? () => {
if (
isBackgroundMusicSubmissionAccountCurrent(
backgroundMusicSubmission,
)
) {
onWalletBalanceMayHaveChanged();
}
}
: onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (
backgroundMusicSubmission &&
(!backgroundMusicSubmissionUiOwned ||
!isBackgroundMusicSubmissionUiTargetCurrent(
backgroundMusicSubmission,
))
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
@@ -2210,7 +2412,56 @@ export function useImageCanvasGenerationSubmissionWorkflow({
});
}
} catch (error) {
if (canvasDialog) {
if (backgroundMusicSubmission) {
if (!backgroundMusicSubmissionAccepted) {
backgroundMusicSubmissionUiOwned =
backgroundMusicPromptAssist.finishSubmission({
operation: backgroundMusicSubmission.operation,
accepted: false,
});
if (
isBackgroundMusicSubmissionAccountCurrent(
backgroundMusicSubmission,
)
) {
notifyWalletBalanceMayHaveChanged(
onWalletBalanceMayHaveChanged,
);
}
}
if (
backgroundMusicSubmissionUiOwned &&
isBackgroundMusicSubmissionUiTargetCurrent(
backgroundMusicSubmission,
)
) {
const currentScope =
currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(
backgroundMusicSubmission.dialogId,
);
const shouldReopenComposer =
currentScope.activeCanvasGenerationDialogId ===
backgroundMusicSubmission.dialogId &&
(currentDialog?.composerOpen !== false ||
backgroundMusicQueueAcceptedWhileCurrentAndOpen);
updateCanvasGenerationDialogById(
backgroundMusicSubmission.dialogId,
(latestDialog) =>
latestDialog.mode === 'audio-background-music'
? {
...latestDialog,
prompt: backgroundMusicSubmission.prompt,
status: 'failed',
composerOpen: shouldReopenComposer
? true
: latestDialog.composerOpen,
errorMessage: resolveImageGenerationErrorMessage(error),
}
: latestDialog,
);
}
} else if (canvasDialog) {
updateCanvasGenerationDialogById(canvasDialog.id, () => ({
...canvasDialog,
prompt: normalizedPrompt,
@@ -2234,7 +2485,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
addAudioResultLayer,
addVideoResultLayer,
applyQuickEditResultToSourceLayer,
backgroundMusicPromptAssist,
currentUserId,
getCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
isBackgroundMusicSubmissionAccountCurrent,
isBackgroundMusicSubmissionProjectCurrent,
isBackgroundMusicSubmissionUiTargetCurrent,
layerCounterRef,
layers,
quickEditSelectionState,
@@ -155,6 +155,7 @@ function GenerationSurfaceHarness() {
activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog:
dialogs.archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId:
@@ -83,6 +83,9 @@ type ImageCanvasGenerationSurfaceOptions = {
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
getCanvasGenerationDialogById: (
dialogId: string,
) => CanvasGenerationDialogState | undefined;
archiveActiveCanvasGenerationDialog: () => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
@@ -176,6 +179,7 @@ export function useImageCanvasGenerationSurface({
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
@@ -218,6 +222,7 @@ export function useImageCanvasGenerationSurface({
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
@@ -430,6 +435,10 @@ export function useImageCanvasGenerationSurface({
generationWorkflow.isPickingUiDesignSpecFromCanvas
}
generateDialog={generateDialog}
backgroundMusicPromptAssist={
generationWorkflow.backgroundMusicPromptAssist
}
updateCanvasGenerationDialogById={updateCanvasGenerationDialogById}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
generationComposerStyle={generationComposerStyle}
iconComposerStyle={iconComposerStyle}
@@ -416,6 +416,7 @@ function GenerationWorkflowHarness({
activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
@@ -126,6 +126,7 @@ import {
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
import type { CanvasGenerationDialogDraft } from './useCanvasGenerationDialogs';
import { useImageCanvasBackgroundMusicPromptAssist } from './useImageCanvasBackgroundMusicPromptAssist';
import {
applyQueuedEditorGenerationProject,
createEditorGenerationMediaUploadId,
@@ -928,6 +929,9 @@ type GenerationWorkflowOptions = {
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
getCanvasGenerationDialogById: (
dialogId: string,
) => CanvasGenerationDialogState | undefined;
archiveActiveCanvasGenerationDialog: () => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
@@ -979,6 +983,7 @@ export function useImageCanvasGenerationWorkflow({
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
@@ -1011,6 +1016,14 @@ export function useImageCanvasGenerationWorkflow({
const refreshTaskList = useCallback(() => {
setTaskListRefreshKey((key) => key + 1);
}, []);
const backgroundMusicPromptAssist =
useImageCanvasBackgroundMusicPromptAssist({
canvasGenerationDialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
const [
@@ -3617,6 +3630,11 @@ export function useImageCanvasGenerationWorkflow({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
activeCanvasGenerationDialogId: isCanvasGenerationDialog(generateDialog)
? generateDialog.id
: null,
backgroundMusicPromptAssist,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
@@ -3627,6 +3645,7 @@ export function useImageCanvasGenerationWorkflow({
setActiveSidebarPanel,
rememberImageModel,
projectId,
currentUserId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
@@ -4225,6 +4244,7 @@ export function useImageCanvasGenerationWorkflow({
uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
backgroundMusicPromptAssist,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
appendUiAssetExtractionReferences,
@@ -4330,6 +4350,7 @@ export function useImageCanvasGenerationWorkflow({
}),
[
effectiveCharacterAnimationPanel,
backgroundMusicPromptAssist,
characterAnimationPrice,
characterAnimationSourceLayer,
clearDeletedLayerGenerationState,
@@ -2324,26 +2324,29 @@ describe('useImageCanvasProjectPersistence', () => {
);
});
const cachedRaw = globalThis.sessionStorage.getItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
);
expect(cachedRaw).toBeTruthy();
expect(cachedRaw).not.toContain('data:image');
const cached = JSON.parse(cachedRaw ?? '{}') as {
ownerUserId?: string;
project?: EditorProjectSnapshot;
revision?: number;
};
expect(cached.ownerUserId).toBe('user-test');
expect(cached.revision).toBe(1);
expect(cached.project?.resources).toEqual([
expect.objectContaining({
resourceId: 'resource-added-asset-a',
imageSrc: '/generated-character-drafts/editor/assets/asset-a.png',
objectKey: 'generated-character-drafts/editor/assets/asset-a.png',
assetObjectId: 'asset-object-a',
}),
]);
await waitFor(() => {
const cachedRaw = globalThis.sessionStorage.getItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
);
expect(cachedRaw).toBeTruthy();
expect(cachedRaw).not.toContain('data:image');
const cached = JSON.parse(cachedRaw ?? '{}') as {
ownerUserId?: string;
project?: EditorProjectSnapshot;
revision?: number;
};
expect(cached.ownerUserId).toBe('user-test');
expect(cached.revision).toBe(1);
expect(cached.project?.resources).toEqual([
expect.objectContaining({
resourceId: 'resource-added-asset-a',
imageSrc: '/generated-character-drafts/editor/assets/asset-a.png',
objectKey:
'generated-character-drafts/editor/assets/asset-a.png',
assetObjectId: 'asset-object-a',
}),
]);
});
});
it('restores the persisted canvas background color without autosaving on load', async () => {
+331 -6
View File
@@ -963,7 +963,6 @@ html[data-mobile-keyboard-open='true'] #root {
font-family: var(--platform-font-family) !important;
}
.platform-brand-logo {
display: inline-flex;
flex: none;
@@ -3303,8 +3302,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
display: block;
}
.creation-landing__asset-waterfall--masonry
> .creation-landing__asset-card {
.creation-landing__asset-waterfall--masonry > .creation-landing__asset-card {
position: absolute;
box-sizing: border-box;
}
@@ -3495,7 +3493,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
place-items: center;
}
.creation-landing__asset-preview--campaign .creation-landing__asset-preview-media {
.creation-landing__asset-preview--campaign
.creation-landing__asset-preview-media {
position: absolute;
inset: 0;
width: 100%;
@@ -6824,7 +6823,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) max-content max-content max-content;
align-items: center;
gap: 0.10rem;
gap: 0.1rem;
justify-content: start;
position: relative;
border-top: 1px solid rgba(15, 23, 42, 0.06);
@@ -8131,7 +8130,8 @@ button.image-canvas-editor__reference-chip:disabled {
line-height: 1.2;
}
.image-canvas-editor__asset-context-menu button:disabled
.image-canvas-editor__asset-context-menu
button:disabled
.image-canvas-editor__asset-context-menu-reason {
color: #94a3b8;
}
@@ -16851,3 +16851,328 @@ button {
background: #f8fafc;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
/* BGM composerPrompt 计数、助手动作与预设轨道。 */
.image-canvas-editor__generation-composer--background-music {
grid-template-columns: minmax(0, 1fr);
width: min(44rem, calc(100% - 1.5rem));
/*
* 行数交给 `--image` `auto auto auto` 与隐式行多写的显式行即使高度为 0行间距
* 仍然照算收起预设时会凭空多出一段空白同理 `min-height` 归零否则 grid 默认的
* align-content: stretch 会把撑出来的余量摊到各行上
*/
min-height: 0;
}
/* 助手动作与「Suno + 生成」同处底部一行,共享 footer 的 5 列 grid 在这里换成 flex。 */
.image-canvas-editor__generation-composer--background-music
.image-canvas-editor__generation-composer-footer {
display: flex;
align-items: center;
gap: 0.4rem;
padding-top: 0.5rem;
}
/* flex 布局下提交按钮硬编码的 grid-column 自动失效,无需为 BGM 覆盖它。 */
.image-canvas-editor__generation-composer--background-music
.image-canvas-editor__option-popover-anchor--model {
margin-left: auto;
}
.image-canvas-editor__generation-composer--background-music
.image-canvas-editor__generation-submit {
height: 2.25rem;
min-height: 0;
}
.image-canvas-editor__background-music-prompt-actions {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
/*
* 助手动作按钮`.platform-button` 给的是 2.9rem 高与 0.7rem/1rem 内边距比同一面板里
* 2.25rem 的生成按钮高出一截这里按两级选择器压回去避免整排按钮比主操作还重
*/
.image-canvas-editor__background-music-prompt-actions
.image-canvas-editor__background-music-prompt-action {
/* 2.25rem 是本 composer 的控件高度基准(模型 chip 与生成按钮同源)。 */
height: 2.25rem;
min-height: 0;
gap: 0.3rem;
/* `shape="pill"` 走的是 Tailwind 分层 utility压不过未分层的 `.platform-button`
* 1rem 圆角所以胶囊圆角必须在这里显式写死 */
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 999px;
background: transparent;
padding: 0 0.82rem;
color: #475569;
font-size: 0.78rem;
font-weight: 760;
box-shadow: none;
}
.image-canvas-editor__background-music-prompt-action-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.image-canvas-editor__background-music-prompt-action-icon svg {
width: 0.85rem;
height: 0.85rem;
}
/* AI 补全是这一排的主操作,用品牌描边与中性的简化、撤销拉开层级。 */
.image-canvas-editor__background-music-prompt-actions
.image-canvas-editor__background-music-prompt-action--complete {
border-color: var(--image-canvas-brand-border-strong);
color: var(--image-canvas-brand-accent);
}
.image-canvas-editor__background-music-prompt-action--complete:not(
:disabled
):hover {
background: var(--image-canvas-brand-soft);
}
.image-canvas-editor__background-music-prompt-action--simplify:not(
:disabled
):hover {
background: rgba(15, 23, 42, 0.04);
}
/* 撤销权重最低:无边框无底色,只在 hover 时给一层淡底。 */
.image-canvas-editor__background-music-prompt-actions
.image-canvas-editor__background-music-prompt-action--undo {
border-color: transparent;
color: #64748b;
}
.image-canvas-editor__background-music-prompt-action--undo:not(
:disabled
):hover {
background: rgba(15, 23, 42, 0.05);
}
.image-canvas-editor__background-music-prompt-count {
margin-left: auto;
color: #64748b;
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.image-canvas-editor__background-music-prompt-count--over-limit {
color: #dc2626;
}
.image-canvas-editor__background-music-presets {
grid-column: 1 / -1;
display: grid;
gap: 0.32rem;
min-width: 0;
}
.image-canvas-editor__background-music-presets-header {
display: flex;
align-items: center;
gap: 0.4rem;
}
.image-canvas-editor__background-music-presets-title {
color: #475569;
font-size: 0.72rem;
font-weight: 800;
}
/*
* 展开 / 收起只是预设标题旁的一个箭头不该是实心 pill命中区靠 1.75rem 方形保住
* 不做成裸图标那种小到点不中的按钮
*/
.image-canvas-editor__background-music-presets-toggle {
width: 1.75rem;
height: 1.75rem;
min-height: 0;
border: 0;
border-radius: 999px;
background: transparent;
padding: 0;
color: #64748b;
box-shadow: none;
}
.image-canvas-editor__background-music-presets-toggle:not(:disabled):hover {
background: rgba(15, 23, 42, 0.05);
color: #334155;
}
.image-canvas-editor__background-music-presets-toggle svg {
width: 0.9rem;
height: 0.9rem;
}
.image-canvas-editor__background-music-presets-track {
position: relative;
display: grid;
grid-template-columns: max-content minmax(0, 1fr) max-content;
align-items: center;
gap: 0.24rem;
min-width: 0;
padding-bottom: 0.28rem;
}
/* 轨道两侧的箭头是辅助控件默认贴边无框hover 才浮出淡底宽度保持 1.5rem
* 细线两端的 1.74rem 缩进箭头 1.5rem + 轨道 0.24rem 间距因此不用跟着改 */
.image-canvas-editor__background-music-presets-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: 0;
border-radius: 999px;
background: transparent;
color: #94a3b8;
transition:
background-color 160ms ease,
color 160ms ease;
}
.image-canvas-editor__background-music-presets-arrow:not(:disabled):hover {
background: rgba(15, 23, 42, 0.05);
color: #475569;
}
.image-canvas-editor__background-music-presets-arrow:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.image-canvas-editor__background-music-presets-arrow svg {
width: 0.85rem;
height: 0.85rem;
}
.image-canvas-editor__background-music-presets-viewport {
display: flex;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
/* 触摸环境只允许横向平移,纵向手势继续交给画布。 */
touch-action: pan-x;
overscroll-behavior-x: contain;
}
.image-canvas-editor__background-music-presets-viewport::-webkit-scrollbar {
display: none;
}
.image-canvas-editor__background-music-presets-queue {
display: flex;
flex: 0 0 auto;
gap: 0.3rem;
padding-right: 0.3rem;
}
.image-canvas-editor__background-music-preset {
flex: 0 0 auto;
/* 五个中文字的标签加内边距:中间 70% 区域可以稳定看到至少五个词条。 */
max-width: 7.5rem;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.2rem 0.62rem;
font-size: 0.72rem;
font-weight: 700;
line-height: 1.5;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
.image-canvas-editor__background-music-preset:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: -2px;
}
.image-canvas-editor__background-music-preset:disabled,
.image-canvas-editor__background-music-preset--disabled {
opacity: 0.6;
cursor: not-allowed;
}
.image-canvas-editor__background-music-preset--purpose {
border-color: rgba(37, 99, 235, 0.24);
background: rgba(219, 234, 254, 0.7);
color: #1d4ed8;
}
.image-canvas-editor__background-music-preset--atmosphere {
border-color: rgba(217, 119, 6, 0.24);
background: rgba(254, 243, 199, 0.7);
color: #b45309;
}
.image-canvas-editor__background-music-preset--scene {
border-color: rgba(13, 148, 136, 0.24);
background: rgba(204, 251, 241, 0.7);
color: #0f766e;
}
.image-canvas-editor__background-music-presets-hairline {
position: absolute;
right: 1.74rem;
bottom: 0;
left: 1.74rem;
height: 1px;
background: rgba(148, 163, 184, 0.28);
}
.image-canvas-editor__background-music-presets-hairline::after {
content: '';
position: absolute;
bottom: 0;
height: 1px;
background: var(--image-canvas-brand-border-strong, #f97316);
transition:
left 0.12s ease,
width 0.12s ease;
}
.image-canvas-editor__background-music-presets-hairline--left::after {
left: 0;
width: 15%;
}
.image-canvas-editor__background-music-presets-hairline--center::after {
left: 15%;
width: 70%;
}
.image-canvas-editor__background-music-presets-hairline--right::after {
left: 85%;
width: 15%;
}
@media (max-width: 640px) {
.image-canvas-editor__generation-composer--background-music {
width: calc(100% - 1rem);
}
.image-canvas-editor__background-music-prompt-actions {
flex-wrap: wrap;
}
/* 一行放不下三个助手按钮加 Suno 和生成,窄屏必须允许换行。 */
.image-canvas-editor__generation-composer--background-music
.image-canvas-editor__generation-composer-footer {
flex-wrap: wrap;
}
.image-canvas-editor__background-music-preset {
max-width: 9rem;
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ describe('index stylesheet unread dots', () => {
);
const masonryCardBlock = getCssBlock(
css,
'.creation-landing__asset-waterfall--masonry\n > .creation-landing__asset-card',
'.creation-landing__asset-waterfall--masonry > .creation-landing__asset-card',
);
const cardBlock = getCssBlock(css, '\n.creation-landing__asset-card {');
const tabletQueryIndex = css.indexOf('@media (max-width: 900px)');

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