完善角色动作精选与后台预览

精选与后台素材查询透传正式序列帧和毫秒时长。
列表仅加载首帧,激活预览后再逐帧换签并按完整时长播放。
创作首页与后台复用各自现有媒体组件,损坏动作不回退普通图片。
补齐精选模型、公开卡片和后台审核预览测试。
This commit is contained in:
2026-08-04 10:25:38 +08:00
parent 8c2f48d816
commit 00cff24cc1
10 changed files with 1000 additions and 68 deletions
+12
View File
@@ -332,6 +332,14 @@ export interface AdminEditorAssetListQuery {
limit?: number | null;
}
export interface AdminEditorImageSequenceFramePayload {
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
}
export interface AdminEditorAssetPayload {
assetId: string;
ownerUserId: string;
@@ -362,6 +370,8 @@ export interface AdminEditorAssetPayload {
taskGenerator: string;
taskCostMudPoints: number;
children: AdminEditorAssetPayload[];
imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null;
imageSequenceDurationMs?: number | null;
}
export interface AdminEditorAssetListResponse {
@@ -413,6 +423,8 @@ export interface AdminEditorShowcaseAssetPayload {
rejectedAt?: string | null;
updatedAt: string;
showcaseCategory?: string | null;
imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null;
imageSequenceDurationMs?: number | null;
}
export interface AdminEditorShowcaseListResponse {
@@ -1,8 +1,9 @@
import { X } from 'lucide-react';
import { Pause, Play, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient';
import type { AdminEditorImageSequenceFramePayload } from '../api/adminApiTypes';
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
@@ -18,6 +19,8 @@ export interface AdminPreviewableEditorAsset {
objectKey?: string | null;
assetKind?: string | null;
thumbnailSrc?: string | null;
imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null;
imageSequenceDurationMs?: number | null;
}
export function AdminEditorAssetThumbnail({
@@ -98,6 +101,45 @@ function AdminEditorAssetPreviewMedia({
token: string;
}) {
const mediaKind = resolveAdminAssetMediaKind(entry);
const label = entry.label || entry.assetId;
if (mediaKind === 'image-sequence') {
const sequence = normalizeAdminImageSequence(entry);
return sequence ? (
<AdminEditorImageSequencePreview
entry={entry}
frames={sequence.frames}
durationMs={sequence.durationMs}
token={token}
/>
) : (
<div
aria-label={`角色动作序列损坏:${label}`}
className="admin-asset-query-preview-placeholder admin-asset-query-sequence-error"
>
</div>
);
}
return (
<AdminEditorStandardAssetPreviewMedia
entry={entry}
mediaKind={mediaKind}
token={token}
/>
);
}
function AdminEditorStandardAssetPreviewMedia({
entry,
mediaKind,
token,
}: {
entry: AdminPreviewableEditorAsset;
mediaKind: Exclude<AdminAssetMediaKind, 'image-sequence'>;
token: string;
}) {
const isAudio = mediaKind === 'audio';
const isVideo = mediaKind === 'video';
const mediaSrc = useAdminResolvedAssetUrl(
@@ -162,8 +204,156 @@ function AdminEditorAssetPreviewMedia({
);
}
function AdminEditorImageSequenceFrame({
entry,
frame,
frameKey,
visible,
loaded,
token,
onReady,
}: {
entry: AdminPreviewableEditorAsset;
frame: AdminEditorImageSequenceFramePayload;
frameKey: string;
visible: boolean;
loaded: boolean;
token: string;
onReady: (frameKey: string) => void;
}) {
const resolvedUrl = useAdminResolvedAssetUrl(
token,
frame.imageSrc,
frame.objectKey,
);
if (!resolvedUrl) {
return null;
}
return (
<img
src={resolvedUrl}
alt={visible ? `角色动作预览:${entry.label || entry.assetId}` : ''}
aria-hidden={visible ? undefined : true}
className="admin-asset-query-sequence-frame"
decoding="async"
style={{ opacity: visible && loaded ? 1 : 0, transition: 'none' }}
onLoad={() => onReady(frameKey)}
/>
);
}
function AdminEditorImageSequencePreview({
entry,
frames,
durationMs,
token,
}: {
entry: AdminPreviewableEditorAsset;
frames: AdminEditorImageSequenceFramePayload[];
durationMs: number;
token: string;
}) {
const frameItems = frames.map((frame, index) => ({
frame,
index,
key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join(':'),
}));
const sequenceKey = frameItems.map((item) => item.key).join('|');
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const handleFrameReady = useCallback((frameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
useEffect(() => {
setFrameIndex(0);
setIsPlaying(true);
setLoadedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
}, [firstFrameKey, sequenceKey]);
useEffect(() => {
if (!currentFrameKey) {
setVisibleFrameKey('');
return;
}
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
return;
}
setVisibleFrameKey(currentFrameKey);
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
useEffect(() => {
if (!isPlaying) {
return undefined;
}
const frameIntervalMs = durationMs / frames.length;
const timer = window.setInterval(() => {
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
}, frameIntervalMs);
return () => window.clearInterval(timer);
}, [durationMs, frames.length, isPlaying]);
return (
<div
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}
/>
))}
<div className="admin-asset-query-sequence-controls">
<button
type="button"
aria-label={isPlaying ? '暂停角色动作' : '播放角色动作'}
onClick={() => setIsPlaying((current) => !current)}
>
{isPlaying ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
</button>
<span>{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}</span>
</div>
</div>
);
}
function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) {
const mediaKind = resolveAdminAssetMediaKind(entry);
if (mediaKind === 'image-sequence') {
const sequence = normalizeAdminImageSequence(entry);
const firstFrame = sequence?.frames[0];
return firstFrame
? { src: firstFrame.imageSrc, objectKey: firstFrame.objectKey ?? null }
: { src: '', objectKey: null };
}
if (mediaKind === 'audio') {
return { src: AUDIO_ASSET_COVER_SRC, objectKey: null };
}
@@ -216,19 +406,58 @@ function useAdminAssetThumbnailVisibility() {
return { observeElement, shouldLoad };
}
type AdminAssetMediaKind = 'image' | 'audio' | 'video';
type AdminAssetMediaKind = 'image' | 'audio' | 'video' | 'image-sequence';
function normalizeAdminImageSequence(entry: AdminPreviewableEditorAsset) {
const frames = entry.imageSequenceFrames;
const durationMs = entry.imageSequenceDurationMs;
if (
!Array.isArray(frames) ||
frames.length < 2 ||
typeof durationMs !== 'number' ||
!Number.isFinite(durationMs) ||
durationMs <= 0
) {
return null;
}
const normalizedFrames = frames.flatMap((frame) => {
const imageSrc = frame.imageSrc?.trim() ?? '';
if (
!imageSrc ||
!Number.isFinite(frame.width) ||
frame.width <= 0 ||
!Number.isFinite(frame.height) ||
frame.height <= 0
) {
return [];
}
return [
{
...frame,
imageSrc,
objectKey: frame.objectKey?.trim() || null,
assetObjectId: frame.assetObjectId?.trim() || null,
},
];
});
return normalizedFrames.length === frames.length
? { frames: normalizedFrames, durationMs }
: null;
}
function resolveAdminAssetMediaKind(
entry: AdminPreviewableEditorAsset,
): AdminAssetMediaKind {
const assetKind = entry.assetKind?.trim() ?? '';
if (assetKind === 'character-animation') {
return 'image-sequence';
}
const pathMediaKind =
resolveAdminAssetMediaKindFromPath(entry.imageSrc) ??
resolveAdminAssetMediaKindFromPath(entry.objectKey ?? '');
if (pathMediaKind) {
return pathMediaKind;
}
const assetKind = entry.assetKind?.trim() ?? '';
if (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
@@ -1068,7 +1068,7 @@ test('后台素材查询点击图片缩略图可打开放大预览', async () =>
});
});
test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () => {
test('后台素材查询在现有预览弹窗播放完整角色动作序列', async () => {
const user = userEvent.setup();
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
@@ -1081,17 +1081,41 @@ test('后台素材查询将角色动画首帧 PNG 作为图片预览', async ()
assetKind: 'character-animation',
thumbnailSrc:
'/generated-animations/editor/source-1/task-1/frame00.png',
imageSequenceFrames: [
{
imageSrc:
'/generated-animations/editor/source-1/task-1/frame00.png',
objectKey:
'generated-animations/editor/source-1/task-1/frame00.png',
width: 192,
height: 256,
},
{
imageSrc:
'/generated-animations/editor/source-1/task-1/frame01.png',
objectKey:
'generated-animations/editor/source-1/task-1/frame01.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 250,
},
],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
read: {
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
signedUrl: 'https://signed.example.com/character-animation-frame00.png',
expiresAt: '2026-07-04T11:00:00Z',
vi.mocked(getAdminAssetReadUrl).mockImplementation(
async (_token, request) => {
const objectKey = request.objectKey ?? undefined;
return {
read: {
objectKey,
signedUrl: `https://signed.example.com/${objectKey ?? ''}`,
expiresAt: '2026-07-04T11:00:00Z',
},
};
},
});
);
render(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
@@ -1102,15 +1126,31 @@ test('后台素材查询将角色动画首帧 PNG 作为图片预览', async ()
);
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
const image = await within(dialog).findByRole('img', {
name: '图片预览:角色动作首帧',
});
expect(
await within(dialog).findByRole('button', {
name: '暂停角色动作',
}),
).toBeTruthy();
await waitFor(() => {
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/character-animation-frame00.png',
);
expect(
dialog.querySelectorAll('.admin-asset-query-sequence-frame'),
).toHaveLength(2);
});
expect(within(dialog).queryByLabelText('视频预览:角色动作首帧')).toBeNull();
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
expireSeconds: 300,
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-animations/editor/source-1/task-1/frame01.png',
expireSeconds: 300,
});
await user.click(
within(dialog).getByRole('button', { name: '暂停角色动作' }),
);
expect(
within(dialog).getByRole('button', { name: '播放角色动作' }),
).toBeTruthy();
});
test('后台素材查询音频素材使用统一封面缩略图', async () => {
@@ -318,6 +318,101 @@ test('后台精选审核缩略图进入视口后换签并可打开图片预览',
expect(screen.queryByRole('dialog', { name: '精选素材详情' })).toBeNull();
});
test('后台精选审核在共用预览弹窗播放完整角色动作', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
label: '待机动作',
assetKind: 'character-animation',
imageSrc: '/generated/action/frame-01.png',
objectKey: 'generated/action/frame-01.png',
imageSequenceFrames: [
{
imageSrc: '/generated/action/frame-01.png',
objectKey: 'generated/action/frame-01.png',
width: 192,
height: 256,
},
{
imageSrc: '/generated/action/frame-02.png',
objectKey: 'generated/action/frame-02.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 250,
},
],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockImplementation(
async (_token, request) => {
const objectKey = request.objectKey ?? undefined;
return {
read: {
objectKey,
signedUrl: `https://signed.example.com/${objectKey ?? ''}`,
expiresAt: '2026-07-04T11:00:00Z',
},
};
},
);
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.click(await screen.findByTitle('预览素材'));
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
expect(
await within(dialog).findByRole('button', { name: '暂停角色动作' }),
).toBeTruthy();
await waitFor(() => {
expect(
dialog.querySelectorAll('.admin-asset-query-sequence-frame'),
).toHaveLength(2);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated/action/frame-02.png',
expireSeconds: 300,
});
});
test('后台精选审核对损坏角色动作显示错误而不回退首帧', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
label: '损坏动作',
assetKind: 'character-animation',
imageSequenceFrames: null,
imageSequenceDurationMs: null,
},
],
nextCursor: null,
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.click(await screen.findByTitle('预览素材'));
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
expect(
within(dialog).getByLabelText('角色动作序列损坏:损坏动作'),
).toBeTruthy();
expect(
within(dialog).queryByRole('img', { name: '图片预览:损坏动作' }),
).toBeNull();
});
test('后台精选审核将无 objectKey 的绝对 OSS 图片地址换签后预览', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
+48
View File
@@ -1703,6 +1703,54 @@ button:disabled {
object-fit: contain;
}
.admin-asset-query-sequence-preview {
position: relative;
width: min(100%, 720px);
min-height: min(64dvh, 560px);
overflow: hidden;
}
.admin-asset-query-sequence-frame {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
.admin-asset-query-sequence-controls {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
border-radius: 999px;
background: rgba(43, 31, 22, 0.82);
color: #fff;
padding: 6px 10px;
font-size: 12px;
}
.admin-asset-query-sequence-controls button {
display: grid;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
place-items: center;
}
.admin-asset-query-sequence-error {
display: grid;
color: #9d3127;
place-items: center;
}
.admin-asset-query-preview-audio {
display: grid;
justify-items: center;
@@ -1,6 +1,13 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor, within } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContextType } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -147,6 +154,7 @@ describe('CreationLandingView', () => {
listPublicEditorProjectResourcesMock.mockReset();
createEditorProjectMock.mockReset();
toggleEditorShowcaseAssetLikeMock.mockReset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -470,6 +478,77 @@ describe('CreationLandingView', () => {
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
});
it('plays a character action on card focus and in the preview dialog', async () => {
const user = userEvent.setup();
const setIntervalSpy = vi.spyOn(window, 'setInterval');
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-action',
projectId: 'editor-showcase',
showcaseId: 'showcase-action',
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;
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(0);
fireEvent.focus(openButton);
await waitFor(() => {
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(2);
});
expect(
setIntervalSpy.mock.calls.some(([, delay]) => delay === 125),
).toBe(true);
await user.click(openButton);
expect(
await screen.findByRole('button', { name: '暂停角色动作' }),
).toBeTruthy();
expect(
document.querySelectorAll(
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
),
).toHaveLength(2);
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
expect(
screen.getByRole('button', { name: '播放角色动作' }),
).toBeTruthy();
setIntervalSpy.mockRestore();
});
it('likes a featured asset from the list card action', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
@@ -611,6 +690,19 @@ describe('CreationLandingView', () => {
taskId: 'task-2',
assetKind: 'character-animation',
priceMudPoints: 40,
imageSequenceFrames: [
{
imageSrc: 'data:image/png;base64,animation-1',
width: 512,
height: 512,
},
{
imageSrc: 'data:image/png;base64,animation-2',
width: 512,
height: 512,
},
],
imageSequenceDurationMs: 4_000,
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [
@@ -1,4 +1,10 @@
import { Film, Image as ImageIcon, Music2 } from 'lucide-react';
import {
Film,
Image as ImageIcon,
Music2,
Pause,
Play,
} from 'lucide-react';
import {
useCallback,
useEffect,
@@ -239,7 +245,10 @@ function getPreviewIcon(preview: ShowcaseAssetPreview) {
if (preview.mediaType === 'audio') {
return <Music2 aria-hidden="true" />;
}
if (preview.mediaType === 'video') {
if (
preview.mediaType === 'video' ||
preview.mediaType === 'image-sequence'
) {
return <Film aria-hidden="true" />;
}
return <ImageIcon aria-hidden="true" />;
@@ -276,9 +285,11 @@ function ShowcaseAudioPreview({
function ShowcasePreview({
preview,
variant = 'card',
sequencePlaying = false,
}: {
preview: ShowcaseAssetPreview;
variant?: 'card' | 'modal' | 'thumb';
sequencePlaying?: boolean;
}) {
if (preview.mediaType === 'audio') {
return (
@@ -294,9 +305,172 @@ function ShowcasePreview({
/>
);
}
if (preview.mediaType === 'image-sequence') {
return (
<ShowcaseImageSequencePreview
preview={preview}
variant={variant}
playing={sequencePlaying}
/>
);
}
return <ShowcaseImagePreview preview={preview} variant={variant} />;
}
function ShowcaseImageSequenceFrame({
preview,
frame,
frameKey,
visible,
loaded,
onReady,
}: {
preview: ShowcaseAssetPreview;
frame: NonNullable<ShowcaseAssetPreview['imageSequenceFrames']>[number];
frameKey: string;
visible: boolean;
loaded: boolean;
onReady: (frameKey: string) => void;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(frame.imageSrc, {
objectKey: frame.objectKey,
refreshKey: `${preview.id}:${frameKey}`,
});
if (!resolvedUrl) {
return null;
}
return (
<img
src={resolvedUrl}
alt={visible ? `角色动作预览:${preview.label}` : ''}
aria-hidden={visible ? undefined : true}
className="creation-landing__image-sequence-frame"
decoding="async"
style={{
opacity: visible && loaded ? 1 : 0,
transition: 'none',
}}
onLoad={() => onReady(frameKey)}
/>
);
}
function ShowcaseImageSequencePreview({
preview,
variant,
playing,
}: {
preview: ShowcaseAssetPreview;
variant: 'card' | 'modal' | 'thumb';
playing: boolean;
}) {
const frames = preview.imageSequenceFrames ?? [];
const durationMs = preview.imageSequenceDurationMs ?? 0;
const shouldMountSequence = variant === 'modal' || playing;
const frameItems = frames.map((frame, index) => ({
frame,
index,
key: [preview.id, frame.objectKey ?? '', frame.imageSrc, index].join(':'),
}));
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(shouldMountSequence);
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const handleFrameReady = useCallback((frameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
useEffect(() => {
setFrameIndex(0);
setLoadedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
setIsPlaying(shouldMountSequence);
}, [firstFrameKey, frameSequenceKey, shouldMountSequence]);
useEffect(() => {
if (!currentFrameKey) {
setVisibleFrameKey('');
return;
}
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
return;
}
setVisibleFrameKey(currentFrameKey);
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
useEffect(() => {
if (!isPlaying || frames.length < 2 || durationMs <= 0) {
return undefined;
}
const frameIntervalMs = durationMs / frames.length;
const timer = window.setInterval(() => {
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
}, frameIntervalMs);
return () => window.clearInterval(timer);
}, [durationMs, frames.length, isPlaying]);
if (!shouldMountSequence) {
return <ShowcaseImagePreview preview={preview} variant={variant} />;
}
const mediaClassName =
variant === 'modal'
? 'creation-landing__showcase-media'
: 'creation-landing__asset-preview-media';
return (
<div
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}
/>
))}
{variant === 'modal' ? (
<div className="creation-landing__image-sequence-controls">
<button
type="button"
aria-label={isPlaying ? '暂停角色动作' : '播放角色动作'}
onClick={() => setIsPlaying((current) => !current)}
>
{isPlaying ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
</button>
<span>{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}</span>
</div>
) : null}
</div>
);
}
function ShowcaseImagePreview({
preview,
variant,
@@ -344,7 +518,13 @@ function ShowcaseVideoPreview({
);
}
function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
function ShowcaseBundlePreview({
item,
playSequences,
}: {
item: ShowcaseAssetItem;
playSequences: boolean;
}) {
const visiblePreviews = item.previews.slice(0, 4);
const primaryPreview = visiblePreviews[0];
const previewClassName = [
@@ -372,7 +552,10 @@ function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
className="creation-landing__asset-preview-tile"
aria-label={preview.label}
>
<ShowcasePreview preview={preview} />
<ShowcasePreview
preview={preview}
sequencePlaying={playSequences}
/>
</span>
))}
{item.previews.length > visiblePreviews.length ? (
@@ -425,7 +608,11 @@ function CreationShowcaseModal({
/>
<div className="creation-landing__showcase-modal-stage">
<div className="creation-landing__showcase-current">
<ShowcasePreview preview={activePreview} variant="modal" />
<ShowcasePreview
preview={activePreview}
variant="modal"
sequencePlaying
/>
</div>
</div>
<div className="creation-landing__showcase-dock">
@@ -468,7 +655,8 @@ function CreationShowcaseModal({
>
<span className="creation-landing__showcase-thumb-media">
{preview.mediaType === 'image' ||
preview.mediaType === 'audio' ? (
preview.mediaType === 'audio' ||
preview.mediaType === 'image-sequence' ? (
<ShowcasePreview preview={preview} variant="thumb" />
) : (
getPreviewIcon(preview)
@@ -518,6 +706,8 @@ export function CreationLandingView({
useState<ShowcaseTabId>('all');
const [selectedShowcaseItem, setSelectedShowcaseItem] =
useState<ShowcaseAssetItem | null>(null);
const [activeShowcaseSequenceCardId, setActiveShowcaseSequenceCardId] =
useState<string | null>(null);
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
const [likedShowcaseIds, setLikedShowcaseIds] = useState<Set<string>>(
() => new Set(),
@@ -904,12 +1094,32 @@ export function CreationLandingView({
<button
type="button"
className="creation-landing__asset-card-open"
onPointerEnter={() =>
setActiveShowcaseSequenceCardId(item.id)
}
onPointerLeave={() =>
setActiveShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
onFocus={() => setActiveShowcaseSequenceCardId(item.id)}
onBlur={() =>
setActiveShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
onClick={() => {
setSelectedShowcaseItem(item);
setSelectedShowcaseIndex(0);
}}
>
<ShowcaseBundlePreview item={item} />
<ShowcaseBundlePreview
item={item}
playSequences={
activeShowcaseSequenceCardId === item.id &&
!selectedShowcaseItem
}
/>
<div className="creation-landing__asset-meta">
<h3>{item.label}</h3>
<p>{item.prompt}</p>
@@ -375,6 +375,19 @@ describe('creationShowcaseModel', () => {
label: '待机动画',
assetKind: 'character-animation',
imageSrc: 'data:image/png;base64,animation',
imageSequenceFrames: [
{
imageSrc: 'data:image/png;base64,animation-1',
width: 512,
height: 512,
},
{
imageSrc: 'data:image/png;base64,animation-2',
width: 512,
height: 512,
},
],
imageSequenceDurationMs: 4_000,
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [
@@ -555,46 +568,118 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.previews[0]?.mediaType).toBe('video');
});
it('infers costs from generated asset kinds when stored price is absent', () => {
it.each([
{ frameCount: 32, durationMs: 4_000 },
{ frameCount: 40, durationMs: 5_000 },
{ frameCount: 48, durationMs: 6_000 },
])(
'carries a $frameCount-frame/$durationMs ms character action into the sequence preview',
({ frameCount, durationMs }) => {
const frames = Array.from({ length: frameCount }, (_, index) => ({
imageSrc: `/generated/action/frame-${index + 1}.png`,
objectKey: `generated/action/frame-${index + 1}.png`,
assetObjectId: `asset-object-frame-${index + 1}`,
width: 192,
height: 256,
}));
const items = buildCreationShowcaseItems({
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'animation-1',
label: '待机动作',
assetKind: 'character-animation',
imageSrc: frames[0]?.imageSrc,
objectKey: frames[0]?.objectKey,
imageSequenceFrames: frames,
imageSequenceDurationMs: durationMs,
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.previews[0]).toMatchObject({
mediaType: 'image-sequence',
imageSequenceDurationMs: durationMs,
});
expect(items[0]?.previews[0]?.imageSequenceFrames).toHaveLength(
frameCount,
);
},
);
it('omits a damaged character action instead of falling back to its first frame', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'character-1',
label: '游戏角色图',
imageSrc: '/generated-editor-assets/character.png',
objectKey: 'generated-editor-assets/character.png',
assetKind: 'character',
model: 'gpt-image-2',
prompt: '角色提示词',
}),
createResource({
resourceId: 'animation-1',
label: '待机动画',
resourceId: 'animation-broken',
label: '损坏动作',
assetKind: 'character-animation',
imageSrc: '/generated-editor-videos/animation.mp4',
objectKey: 'generated-editor-videos/animation.mp4',
model: 'seedance2.0-fast',
durationSeconds: 6,
generationInputs: {
fields: [{ title: '清晰度', value: '720p' }],
references: [
{
title: '角色图片',
label: '游戏角色图',
refType: 'project-resource',
refId: 'character-1',
},
],
},
imageSrc: '/generated/action/frame-01.png',
objectKey: 'generated/action/frame-01.png',
imageSequenceFrames: [
{
imageSrc: '/generated/action/frame-01.png',
objectKey: 'generated/action/frame-01.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 5_000,
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe('123泥点');
expect(items[0]?.previews[0]?.objectKey).toBe(
'generated-editor-assets/character.png',
);
expect(items).toEqual([]);
});
it.each([
{ durationMs: 5_000, expectedCost: '103泥点' },
{ durationMs: 6_000, expectedCost: '123泥点' },
])(
'infers costs from $durationMs ms character actions when stored price is absent',
({ durationMs, expectedCost }) => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
projectResources: [
createResource({
resourceId: 'character-1',
label: '游戏角色图',
imageSrc: '/generated-editor-assets/character.png',
objectKey: 'generated-editor-assets/character.png',
assetKind: 'character',
model: 'gpt-image-2',
prompt: '角色提示词',
}),
createResource({
resourceId: 'animation-1',
label: '待机动画',
assetKind: 'character-animation',
imageSrc: '/generated-editor-videos/animation.mp4',
objectKey: 'generated-editor-videos/animation.mp4',
model: 'seedance2.0-fast',
imageSequenceDurationMs: durationMs,
generationInputs: {
fields: [{ title: '清晰度', value: '720p' }],
references: [
{
title: '角色图片',
label: '游戏角色图',
refType: 'project-resource',
refId: 'character-1',
},
],
},
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe(expectedCost);
expect(items[0]?.previews[0]?.objectKey).toBe(
'generated-editor-assets/character.png',
);
},
);
});
@@ -1,6 +1,7 @@
import type {
EditorAssetGenerationInputReference,
EditorAssetSnapshot,
EditorImageSequenceFrameResult,
EditorProjectResourceSnapshot,
EditorShowcaseCampaignSnapshot,
} from '../../services/image-editor/editorProjectClient';
@@ -19,7 +20,11 @@ import {
export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing';
export type ShowcaseAssetMediaType = 'image' | 'video' | 'audio';
export type ShowcaseAssetMediaType =
| 'image'
| 'video'
| 'audio'
| 'image-sequence';
export type ShowcaseAssetPreview = {
id: string;
@@ -30,6 +35,8 @@ export type ShowcaseAssetPreview = {
mediaType: ShowcaseAssetMediaType;
width?: number;
height?: number;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
};
export type ShowcaseAssetItem = {
@@ -248,7 +255,8 @@ function projectResourceToShowcaseAsset(
model: resource.model ?? null,
provider: resource.provider ?? null,
taskId: resource.taskId ?? null,
durationSeconds: resource.durationSeconds,
imageSequenceFrames: resource.imageSequenceFrames,
imageSequenceDurationMs: resource.imageSequenceDurationMs,
sourceResourceId: resource.sourceResourceId,
assetKind: resource.assetKind,
showcaseCategory: normalizeShowcaseCategory(
@@ -396,6 +404,9 @@ function isVideoAsset(asset: EditorAssetSnapshot) {
function resolveAssetMediaType(
asset: EditorAssetSnapshot,
): ShowcaseAssetMediaType {
if (isCharacterAnimationAsset(asset)) {
return 'image-sequence';
}
if (isAudioAsset(asset)) {
return 'audio';
}
@@ -794,18 +805,23 @@ function inferAssetCost(asset: EditorAssetSnapshot) {
return calculateCharacterAnimationPrice(
model ?? CHARACTER_ANIMATION_MODEL,
inferVideoResolution(asset, ['480p', '720p'], '480p') as '480p' | '720p',
normalizeInt(asset.durationSeconds, 4, 4, 6),
normalizeInt(
asset.imageSequenceDurationMs
? asset.imageSequenceDurationMs / 1_000
: undefined,
4,
4,
6,
),
);
}
if (kind === 'video' || isVideoAsset(asset)) {
return calculateEditorVideoPrice(
model ?? DEFAULT_VIDEO_MODEL,
inferVideoResolution(asset, ['480p', '720p', '1080p'], '480p') as
| '480p'
| '720p'
| '1080p',
'480p' | '720p' | '1080p',
normalizeInt(
asset.durationSeconds,
asset.generationInputs?.durationSeconds,
DEFAULT_VIDEO_DURATION_SECONDS,
4,
15,
@@ -851,8 +867,70 @@ function resolveGroupCost(assets: EditorAssetSnapshot[]) {
return `${totalCost}泥点`;
}
function toAssetPreview(asset: EditorAssetSnapshot): ShowcaseAssetPreview {
function normalizeShowcaseImageSequence(asset: EditorAssetSnapshot) {
const frames = asset.imageSequenceFrames;
const durationMs = asset.imageSequenceDurationMs;
if (
!Array.isArray(frames) ||
frames.length < 2 ||
typeof durationMs !== 'number' ||
!Number.isFinite(durationMs) ||
durationMs <= 0
) {
return null;
}
const normalizedFrames = frames.flatMap((frame) => {
const imageSrc = frame.imageSrc?.trim() ?? '';
if (
!imageSrc ||
!Number.isFinite(frame.width) ||
frame.width <= 0 ||
!Number.isFinite(frame.height) ||
frame.height <= 0
) {
return [];
}
return [
{
...frame,
imageSrc,
objectKey: frame.objectKey?.trim() || null,
assetObjectId: frame.assetObjectId?.trim() || null,
},
];
});
if (normalizedFrames.length !== frames.length) {
return null;
}
return { frames: normalizedFrames, durationMs };
}
function toAssetPreview(
asset: EditorAssetSnapshot,
): ShowcaseAssetPreview | null {
const mediaType = resolveAssetMediaType(asset);
if (mediaType === 'image-sequence') {
const sequence = normalizeShowcaseImageSequence(asset);
if (!sequence) {
return null;
}
const firstFrame = sequence.frames[0];
if (!firstFrame) {
return null;
}
return {
id: asset.assetId,
label: asset.label,
src: firstFrame.imageSrc,
coverSrc: null,
objectKey: firstFrame.objectKey,
mediaType,
width: firstFrame.width,
height: firstFrame.height,
imageSequenceFrames: sequence.frames,
imageSequenceDurationMs: sequence.durationMs,
};
}
return {
id: asset.assetId,
label: asset.label,
@@ -872,7 +950,10 @@ function toLibraryShowcaseItem(
const sortedAssets = sortGroupAssets(group.assets);
const previews = sortedAssets
.map(toAssetPreview)
.filter((preview) => preview.src.trim());
.filter(
(preview): preview is ShowcaseAssetPreview =>
Boolean(preview?.src.trim()),
);
if (!previews.length) {
return null;
}
+40
View File
@@ -3436,6 +3436,46 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
object-position: center;
}
.creation-landing__image-sequence {
position: relative;
overflow: hidden;
}
.creation-landing__image-sequence-frame {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: inherit;
object-position: inherit;
}
.creation-landing__image-sequence-controls {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
z-index: 2;
display: flex;
align-items: center;
gap: 0.45rem;
border-radius: 999px;
background: rgba(24, 18, 14, 0.78);
color: #fff;
padding: 0.35rem 0.55rem;
font-size: 0.75rem;
}
.creation-landing__image-sequence-controls button {
display: grid;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
place-items: center;
}
.creation-landing__asset-preview--campaign {
place-items: center;
}