From a3d3a1b8bac22d62a4e65acc0d7535301eac637e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 14:25:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=8A=A8=E4=BD=9C=E9=A2=84=E8=A7=88=E6=8D=A2=E7=AD=BE?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将序列帧 signed URL、解析与加载状态统一上移到预览父组件。 按当前序列限制缓存并在序列、token 或弹窗生命周期结束时清理在途请求。 新增跨三帧窗口延迟换签与回绕复用的回归测试,并同步架构文档。 --- .../components/AdminEditorAssetMedia.test.tsx | 166 ++++++++ .../src/components/AdminEditorAssetMedia.tsx | 392 ++++++++++++++---- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- 3 files changed, 475 insertions(+), 85 deletions(-) create mode 100644 apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx new file mode 100644 index 000000000..dcebe3244 --- /dev/null +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx @@ -0,0 +1,166 @@ +/* @vitest-environment jsdom */ + +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; + +import { getAdminAssetReadUrl } from '../api/adminApiClient'; +import { AdminEditorAssetPreviewDialog } from './AdminEditorAssetMedia'; + +vi.mock('../api/adminApiClient', () => ({ + getAdminAssetReadUrl: vi.fn(), + isAdminApiError: vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number', + ), +})); + +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +test('角色动作预览跨窗口回播时复用父级换签缓存并等待目标帧就绪', async () => { + vi.useFakeTimers(); + const frameObjectKeys = [ + 'generated-animations/editor/task-cache/frame00.png', + 'generated-animations/editor/task-cache/frame01.png', + 'generated-animations/editor/task-cache/frame02.png', + 'generated-animations/editor/task-cache/frame03.png', + 'generated-animations/editor/task-cache/frame04.png', + ] as const; + const delayedFrame = createDeferred<{ + read: { + objectKey: string; + signedUrl: string; + expiresAt: string; + }; + }>(); + vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => { + const objectKey = request.objectKey ?? ''; + if (objectKey === frameObjectKeys[3]) { + return delayedFrame.promise; + } + return Promise.resolve({ + read: { + objectKey, + signedUrl: `https://signed.example.com/${objectKey}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + }); + + render( + ({ + imageSrc: `/${objectKey}`, + objectKey, + width: 192, + height: 256, + })), + imageSequenceDurationMs: 500, + }} + token="admin-token" + onClose={vi.fn()} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '素材预览' }); + fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + const initialFrames = Array.from( + dialog.querySelectorAll( + '.admin-asset-query-sequence-frame', + ), + ); + expect(initialFrames).toHaveLength(3); + initialFrames.forEach((frame) => fireEvent.load(frame)); + + fireEvent.click(within(dialog).getByRole('button', { name: '播放角色动作' })); + for (const elapsedMs of [100, 40, 60, 40, 60]) { + await act(async () => { + await vi.advanceTimersByTimeAsync(elapsedMs); + }); + } + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: frameObjectKeys[3], + expireSeconds: 300, + }); + expect(resolveVisibleFrameSrc(dialog)).toBe( + `https://signed.example.com/${frameObjectKeys[2]}`, + ); + + await act(async () => { + delayedFrame.resolve({ + read: { + objectKey: frameObjectKeys[3], + signedUrl: `https://signed.example.com/${frameObjectKeys[3]}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + await Promise.resolve(); + }); + const fourthFrame = resolveFrameByObjectKey(dialog, frameObjectKeys[3]); + expect(fourthFrame?.style.opacity).toBe('0'); + expect(resolveVisibleFrameSrc(dialog)).toBe( + `https://signed.example.com/${frameObjectKeys[2]}`, + ); + fireEvent.load(fourthFrame!); + fireEvent.load(resolveFrameByObjectKey(dialog, frameObjectKeys[4])!); + + for (let index = 0; index < 3; index += 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + } + expect(within(dialog).getByText('1/5')).toBeTruthy(); + expect(resolveVisibleFrameSrc(dialog)).toBe( + `https://signed.example.com/${frameObjectKeys[0]}`, + ); + expect( + vi + .mocked(getAdminAssetReadUrl) + .mock.calls.filter( + ([, request]) => request.objectKey === frameObjectKeys[0], + ), + ).toHaveLength(1); + expect( + dialog.querySelectorAll('.admin-asset-query-sequence-frame').length, + ).toBeLessThanOrEqual(3); +}); + +function resolveFrameByObjectKey(dialog: HTMLElement, objectKey: string) { + return Array.from( + dialog.querySelectorAll( + '.admin-asset-query-sequence-frame', + ), + ).find((frame) => frame.src.endsWith(objectKey)); +} + +function resolveVisibleFrameSrc(dialog: HTMLElement) { + return Array.from( + dialog.querySelectorAll( + '.admin-asset-query-sequence-frame', + ), + ).find((frame) => frame.style.opacity === '1')?.src; +} diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx index 33c14e90a..eb36d2b1a 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -206,33 +206,22 @@ function AdminEditorStandardAssetPreviewMedia({ function AdminEditorImageSequenceFrame({ entry, - frame, frameKey, + resolvedUrl, visible, - loaded, - token, - retryGeneration, + ready, onReady, onFailed, }: { entry: AdminPreviewableEditorAsset; - frame: AdminEditorImageSequenceFramePayload; frameKey: string; + resolvedUrl: string; visible: boolean; - loaded: boolean; - token: string; - retryGeneration: number; + ready: boolean; onReady: (frameKey: string) => void; onFailed: (frameKey: string) => void; }) { const failureReportedRef = useRef(false); - const { resolvedUrl, hasError } = useAdminResolvedAssetUrl( - token, - frame.imageSrc, - frame.objectKey, - true, - `${frameKey}:${retryGeneration}`, - ); const reportFailure = useCallback(() => { if (failureReportedRef.current) { return; @@ -241,12 +230,6 @@ function AdminEditorImageSequenceFrame({ onFailed(frameKey); }, [frameKey, onFailed]); - useEffect(() => { - if (hasError) { - reportFailure(); - } - }, [hasError, reportFailure]); - if (!resolvedUrl) { return null; } @@ -257,7 +240,7 @@ function AdminEditorImageSequenceFrame({ aria-hidden={visible ? undefined : true} className="admin-asset-query-sequence-frame" decoding="async" - style={{ opacity: visible && loaded ? 1 : 0, transition: 'none' }} + style={{ opacity: visible && ready ? 1 : 0, transition: 'none' }} onLoad={() => onReady(frameKey)} onError={reportFailure} /> @@ -287,75 +270,73 @@ function AdminEditorImageSequencePreview({ [entry.assetId, frames], ); const sequenceKey = frameItems.map((item) => item.key).join('|'); + const frameCacheOwnerKey = `${token}\u0000${sequenceKey}`; const firstFrameKey = frameItems[0]?.key ?? ''; const [frameIndex, setFrameIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(true); - const [loadedFrameKeys, setLoadedFrameKeys] = useState>( - () => new Set(), + const { + frameCache, + resolveFrames, + markFrameReady, + markFrameFailed, + retryFailedFrames: clearFailedFrameCache, + } = useAdminImageSequenceFrameCache( + token, + frameCacheOwnerKey, + frameItems.length, ); - const [failedFrameKeys, setFailedFrameKeys] = useState>( - () => new Set(), - ); - const [retryGeneration, setRetryGeneration] = useState(0); const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey); + const failedFrameKeys = useMemo( + () => + new Set( + Array.from(frameCache.entries()).flatMap(([frameKey, cached]) => + cached.status === 'failed' ? [frameKey] : [], + ), + ), + [frameCache], + ); const currentFrameItem = frameItems[Math.min(frameIndex, frameItems.length - 1)]; const currentFrameKey = currentFrameItem?.key ?? ''; - const mountedFrameKeys = new Set(); - if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) { - mountedFrameKeys.add(visibleFrameKey); - } - for ( - let offset = 0; - offset < frameItems.length && mountedFrameKeys.size < 3; - offset += 1 - ) { - const item = frameItems[(frameIndex + offset) % frameItems.length]; - if (item && !failedFrameKeys.has(item.key)) { - mountedFrameKeys.add(item.key); + const mountedFrameKeys = useMemo(() => { + const nextMountedFrameKeys = new Set(); + if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) { + nextMountedFrameKeys.add(visibleFrameKey); } - } + for ( + let offset = 0; + offset < frameItems.length && nextMountedFrameKeys.size < 3; + offset += 1 + ) { + const item = frameItems[(frameIndex + offset) % frameItems.length]; + if (item && !failedFrameKeys.has(item.key)) { + nextMountedFrameKeys.add(item.key); + } + } + return nextMountedFrameKeys; + }, [failedFrameKeys, frameIndex, frameItems, visibleFrameKey]); + const mountedFrameItems = useMemo( + () => frameItems.filter((item) => mountedFrameKeys.has(item.key)), + [frameItems, mountedFrameKeys], + ); const availableFrameCount = frameItems.length - failedFrameKeys.size; - const handleFrameReady = useCallback((frameKey: string) => { - setLoadedFrameKeys((currentKeys) => { - if (currentKeys.has(frameKey)) { - return currentKeys; - } - const nextKeys = new Set(currentKeys); - nextKeys.add(frameKey); - return nextKeys; - }); - }, []); - - const handleFrameFailed = useCallback((frameKey: string) => { - setFailedFrameKeys((currentKeys) => { - if (currentKeys.has(frameKey)) { - return currentKeys; - } - const nextKeys = new Set(currentKeys); - nextKeys.add(frameKey); - return nextKeys; - }); - }, []); - const retryFailedFrames = useCallback(() => { setFrameIndex(0); setIsPlaying(true); - setLoadedFrameKeys(new Set()); - setFailedFrameKeys(new Set()); - setVisibleFrameKey(firstFrameKey); - setRetryGeneration((current) => current + 1); - }, [firstFrameKey]); + clearFailedFrameCache(); + }, [clearFailedFrameCache]); useEffect(() => { setFrameIndex(0); setIsPlaying(true); - setLoadedFrameKeys(new Set()); - setFailedFrameKeys(new Set()); setVisibleFrameKey(firstFrameKey); }, [firstFrameKey, sequenceKey]); + useEffect(() => { + resolveFrames(mountedFrameItems); + }, [mountedFrameItems, resolveFrames]); + useEffect(() => { if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) { return; @@ -378,11 +359,14 @@ function AdminEditorImageSequencePreview({ setVisibleFrameKey(''); return; } - if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) { + if ( + frameCache.get(currentFrameKey)?.status !== 'ready' && + visibleFrameKey + ) { return; } setVisibleFrameKey(currentFrameKey); - }, [currentFrameKey, loadedFrameKeys, visibleFrameKey]); + }, [currentFrameKey, frameCache, visibleFrameKey]); useEffect(() => { if (!isPlaying || availableFrameCount < 2) { @@ -414,22 +398,21 @@ function AdminEditorImageSequencePreview({ aria-label={`角色动作预览:${entry.label || entry.assetId}`} className="admin-asset-query-preview-media admin-asset-query-sequence-preview" > - {frameItems - .filter((item) => mountedFrameKeys.has(item.key)) - .map((item) => ( + {mountedFrameItems.map((item) => { + const cachedFrame = frameCache.get(item.key); + return ( - ))} + ); + })}