完善角色动作精选与后台预览
精选与后台素材查询透传正式序列帧和毫秒时长。 列表仅加载首帧,激活预览后再逐帧换签并按完整时长播放。 创作首页与后台复用各自现有媒体组件,损坏动作不回退普通图片。 补齐精选模型、公开卡片和后台审核预览测试。
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user