合并主分支
解决简单冲突
This commit is contained in:
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
@@ -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 composer:Prompt 计数、助手动作与预设轨道。 */
|
||||
.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
@@ -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
Reference in New Issue
Block a user