diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 88933171a..f590021f3 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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 { diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx index 9e93be572..3b3b41e99 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -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 ? ( + + ) : ( +
+ 序列数据损坏,无法预览 +
+ ); + } + + return ( + + ); +} + +function AdminEditorStandardAssetPreviewMedia({ + entry, + mediaKind, + token, +}: { + entry: AdminPreviewableEditorAsset; + mediaKind: Exclude; + 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 ( + {visible 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>( + () => 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 ( +
+ {frameItems.map((item) => ( + + ))} +
+ + {`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`} +
+
+ ); +} + 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' || diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 5bd550cd3..f27ccc5e2 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -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( , @@ -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 () => { diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index f207ba285..238bdeba5 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -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( + , + ); + 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( + , + ); + 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: [ diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 191fbd840..d53628c5b 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -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; diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index a85a5d614..3bb0c5d51 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -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: [ diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index b3496951f..cca902420 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -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