优化角色动作预览加载与交互

后台和创作主页播放器只挂载当前帧与有界预读帧,避免一次换签全部序列。

浏览器播放间隔限制在定时器安全上限,不恢复已移除的最低帧间隔。

创作主页分别跟踪悬停和焦点,并遵循系统减少动态效果偏好。

后台播放按钮扩展为触控友好的四十四像素点击区域。

补充预读窗口、交互状态和减少动态效果回归测试与项目记忆。
This commit is contained in:
2026-08-04 11:41:34 +08:00
parent ab4868d432
commit f5b13915e0
6 changed files with 219 additions and 49 deletions
@@ -269,6 +269,13 @@ function AdminEditorImageSequencePreview({
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const mountedFrameKeys = new Set([visibleFrameKey]);
for (let offset = 0; offset < Math.min(3, frameItems.length); offset += 1) {
const item = frameItems[(frameIndex + offset) % frameItems.length];
if (item) {
mountedFrameKeys.add(item.key);
}
}
const handleFrameReady = useCallback((frameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
@@ -303,7 +310,7 @@ function AdminEditorImageSequencePreview({
if (!isPlaying) {
return undefined;
}
const frameIntervalMs = durationMs / frames.length;
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
const timer = window.setInterval(() => {
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
}, frameIntervalMs);
@@ -315,18 +322,20 @@ function AdminEditorImageSequencePreview({
aria-label={`角色动作预览:${entry.label || entry.assetId}`}
className="admin-asset-query-preview-media admin-asset-query-sequence-preview"
>
{frameItems.map((item) => (
<AdminEditorImageSequenceFrame
key={item.key}
entry={entry}
frame={item.frame}
frameKey={item.key}
visible={item.key === visibleFrameKey}
loaded={loadedFrameKeys.has(item.key)}
token={token}
onReady={handleFrameReady}
/>
))}
{frameItems
.filter((item) => mountedFrameKeys.has(item.key))
.map((item) => (
<AdminEditorImageSequenceFrame
key={item.key}
entry={entry}
frame={item.frame}
frameKey={item.key}
visible={item.key === visibleFrameKey}
loaded={loadedFrameKeys.has(item.key)}
token={token}
onReady={handleFrameReady}
/>
))}
<div className="admin-asset-query-sequence-controls">
<button
type="button"
@@ -1098,6 +1098,30 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as
width: 192,
height: 256,
},
{
imageSrc:
'/generated-animations/editor/source-1/task-1/frame02.png',
objectKey:
'generated-animations/editor/source-1/task-1/frame02.png',
width: 192,
height: 256,
},
{
imageSrc:
'/generated-animations/editor/source-1/task-1/frame03.png',
objectKey:
'generated-animations/editor/source-1/task-1/frame03.png',
width: 192,
height: 256,
},
{
imageSrc:
'/generated-animations/editor/source-1/task-1/frame04.png',
objectKey:
'generated-animations/editor/source-1/task-1/frame04.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 250,
},
@@ -1126,15 +1150,15 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as
);
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
expect(
await user.click(
await within(dialog).findByRole('button', {
name: '暂停角色动作',
}),
).toBeTruthy();
);
await waitFor(() => {
expect(
dialog.querySelectorAll('.admin-asset-query-sequence-frame'),
).toHaveLength(2);
).toHaveLength(3);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
@@ -1144,10 +1168,15 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as
objectKey: 'generated-animations/editor/source-1/task-1/frame01.png',
expireSeconds: 300,
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-animations/editor/source-1/task-1/frame02.png',
expireSeconds: 300,
});
expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-animations/editor/source-1/task-1/frame03.png',
expireSeconds: 300,
});
await user.click(
within(dialog).getByRole('button', { name: '暂停角色动作' }),
);
expect(
within(dialog).getByRole('button', { name: '播放角色动作' }),
).toBeTruthy();
+2
View File
@@ -1737,6 +1737,8 @@ button:disabled {
.admin-asset-query-sequence-controls button {
display: grid;
min-width: 44px;
min-height: 44px;
border: 0;
background: transparent;
color: inherit;
@@ -3892,6 +3892,7 @@
- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。
- 验证:`adminRoutes` 必须包含 `gray-release`admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx``apps/admin-web/src/app/adminRoutes.ts``server-rs/crates/api-server/src/modules/admin.rs``docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`
## 角色动作不能靠素材主图或通用生成输入恢复
- 现象:角色动作在整画布导出时正常,但从素材库单项下载只得到第一帧 PNG,拖回画布也成为普通静态图片。
@@ -3912,7 +3913,7 @@
- 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。
- 原因:快照字段、展示 renderer 和私有对象授权是三道独立边界。公开 `imageSrc/objectKey` 只代表首帧,不能让前端自动获得完整帧集;顶层精选 exact grant 也不会自动覆盖其它帧对象。
- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后才逐帧换签,避免再次制造换签突发
- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后也只挂载当前帧和有界预读窗口,避免再次制造换签突发;卡片 hover 与 focus 分别跟踪,只要任一状态仍成立就继续播放,系统请求 `prefers-reduced-motion` 时卡片和弹窗默认暂停,用户仍可在弹窗中手动播放
- 验证:模型 / 组件测试覆盖 4 / 5 / 6 秒动作、损坏序列不回退 PNG、后台两页共用播放器和未激活列表不逐帧请求;SpacetimeDB 测试覆盖主对象、每帧对象、无关对象、跨 owner 与取消展示后的授权撤销。真实浏览器和端到端验收由人工单独执行,不把 unit / component 结果写成 E2E PASS。
## 可复用资源回填必须保持时间戳单调
@@ -506,6 +506,21 @@ describe('CreationLandingView', () => {
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-03.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-04.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-05.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 250,
},
@@ -526,11 +541,23 @@ describe('CreationLandingView', () => {
await waitFor(() => {
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(2);
).toHaveLength(3);
});
expect(setIntervalSpy.mock.calls.some(([, delay]) => delay === 50)).toBe(
true,
);
fireEvent.pointerEnter(openButton);
fireEvent.blur(openButton);
expect(
setIntervalSpy.mock.calls.some(([, delay]) => delay === 125),
).toBe(true);
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(3);
fireEvent.pointerLeave(openButton);
await waitFor(() => {
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(0);
});
await user.click(openButton);
expect(
@@ -540,15 +567,76 @@ describe('CreationLandingView', () => {
document.querySelectorAll(
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
),
).toHaveLength(2);
).toHaveLength(3);
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
expect(
screen.getByRole('button', { name: '播放角色动作' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '播放角色动作' })).toBeTruthy();
setIntervalSpy.mockRestore();
});
it('keeps character action previews paused when reduced motion is requested', async () => {
const user = userEvent.setup();
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({
matches: true,
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
);
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-action-reduced-motion',
projectId: 'editor-showcase',
showcaseId: 'showcase-action-reduced-motion',
label: '缓慢待机动作',
imageSrc: '/public/action/frame-01.png',
objectKey: null,
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
generationCostMudPoints: 103,
imageSequenceFrames: [
{
imageSrc: '/public/action/frame-01.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-02.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 250,
},
]);
renderCreationLanding();
const title = await screen.findByText('缓慢待机动作');
const card = title.closest('.creation-landing__asset-card') as HTMLElement;
const openButton = card.querySelector(
'.creation-landing__asset-card-open',
) as HTMLButtonElement;
fireEvent.focus(openButton);
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(0);
await user.click(openButton);
expect(
await screen.findByRole('button', { name: '播放角色动作' }),
).toBeTruthy();
});
it('likes a featured asset from the list card action', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
@@ -80,6 +80,30 @@ const CREATION_HOME_ASSETS = {
} as const;
const SHOWCASE_LIKE_ICON_SRC = '/creation-home/showcase-like-thumb.png';
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
typeof window.matchMedia === 'function'
? window.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches
: false,
);
useEffect(() => {
if (typeof window.matchMedia !== 'function') {
return undefined;
}
const mediaQuery = window.matchMedia(REDUCED_MOTION_MEDIA_QUERY);
const handleChange = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
setPrefersReducedMotion(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return prefersReducedMotion;
}
const CREATION_FEATURES: CreationFeatureItem[] = [
{
@@ -377,7 +401,7 @@ function ShowcaseImageSequencePreview({
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(shouldMountSequence);
const [isPlaying, setIsPlaying] = useState(playing);
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
@@ -385,6 +409,13 @@ function ShowcaseImageSequencePreview({
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const mountedFrameKeys = new Set([visibleFrameKey]);
for (let offset = 0; offset < Math.min(3, frameItems.length); offset += 1) {
const item = frameItems[(frameIndex + offset) % frameItems.length];
if (item) {
mountedFrameKeys.add(item.key);
}
}
const handleFrameReady = useCallback((frameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
@@ -401,8 +432,8 @@ function ShowcaseImageSequencePreview({
setFrameIndex(0);
setLoadedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
setIsPlaying(shouldMountSequence);
}, [firstFrameKey, frameSequenceKey, shouldMountSequence]);
setIsPlaying(playing);
}, [firstFrameKey, frameSequenceKey, playing]);
useEffect(() => {
if (!currentFrameKey) {
@@ -419,7 +450,7 @@ function ShowcaseImageSequencePreview({
if (!isPlaying || frames.length < 2 || durationMs <= 0) {
return undefined;
}
const frameIntervalMs = durationMs / frames.length;
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
const timer = window.setInterval(() => {
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
}, frameIntervalMs);
@@ -440,17 +471,19 @@ function ShowcaseImageSequencePreview({
aria-label={`角色动作预览:${preview.label}`}
className={`${mediaClassName} creation-landing__image-sequence`}
>
{frameItems.map((item) => (
<ShowcaseImageSequenceFrame
key={item.key}
preview={preview}
frame={item.frame}
frameKey={item.key}
visible={item.key === visibleFrameKey}
loaded={loadedFrameKeys.has(item.key)}
onReady={handleFrameReady}
/>
))}
{frameItems
.filter((item) => mountedFrameKeys.has(item.key))
.map((item) => (
<ShowcaseImageSequenceFrame
key={item.key}
preview={preview}
frame={item.frame}
frameKey={item.key}
visible={item.key === visibleFrameKey}
loaded={loadedFrameKeys.has(item.key)}
onReady={handleFrameReady}
/>
))}
{variant === 'modal' ? (
<div className="creation-landing__image-sequence-controls">
<button
@@ -570,11 +603,13 @@ function ShowcaseBundlePreview({
function CreationShowcaseModal({
item,
activeIndex,
playSequences,
onSelectIndex,
onClose,
}: {
item: ShowcaseAssetItem | null;
activeIndex: number;
playSequences: boolean;
onSelectIndex: (index: number) => void;
onClose: () => void;
}) {
@@ -611,7 +646,7 @@ function CreationShowcaseModal({
<ShowcasePreview
preview={activePreview}
variant="modal"
sequencePlaying
sequencePlaying={playSequences}
/>
</div>
</div>
@@ -679,6 +714,7 @@ export function CreationLandingView({
onOpenCommunity,
searchKeyword = '',
}: CreationLandingViewProps) {
const prefersReducedMotion = usePrefersReducedMotion();
const authUi = useAuthUi();
const isAuthenticated = Boolean(authUi?.user);
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
@@ -706,7 +742,9 @@ export function CreationLandingView({
useState<ShowcaseTabId>('all');
const [selectedShowcaseItem, setSelectedShowcaseItem] =
useState<ShowcaseAssetItem | null>(null);
const [activeShowcaseSequenceCardId, setActiveShowcaseSequenceCardId] =
const [hoveredShowcaseSequenceCardId, setHoveredShowcaseSequenceCardId] =
useState<string | null>(null);
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
useState<string | null>(null);
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
const [likedShowcaseIds, setLikedShowcaseIds] = useState<Set<string>>(
@@ -1095,16 +1133,16 @@ export function CreationLandingView({
type="button"
className="creation-landing__asset-card-open"
onPointerEnter={() =>
setActiveShowcaseSequenceCardId(item.id)
setHoveredShowcaseSequenceCardId(item.id)
}
onPointerLeave={() =>
setActiveShowcaseSequenceCardId((current) =>
setHoveredShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
onFocus={() => setActiveShowcaseSequenceCardId(item.id)}
onFocus={() => setFocusedShowcaseSequenceCardId(item.id)}
onBlur={() =>
setActiveShowcaseSequenceCardId((current) =>
setFocusedShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
@@ -1116,7 +1154,9 @@ export function CreationLandingView({
<ShowcaseBundlePreview
item={item}
playSequences={
activeShowcaseSequenceCardId === item.id &&
!prefersReducedMotion &&
(hoveredShowcaseSequenceCardId === item.id ||
focusedShowcaseSequenceCardId === item.id) &&
!selectedShowcaseItem
}
/>
@@ -1374,6 +1414,7 @@ export function CreationLandingView({
<CreationShowcaseModal
item={selectedShowcaseItem}
activeIndex={selectedShowcaseIndex}
playSequences={!prefersReducedMotion}
onSelectIndex={setSelectedShowcaseIndex}
onClose={() => setSelectedShowcaseItem(null)}
/>