diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx index 73a837610..6fb205851 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -1,5 +1,5 @@ import { Pause, Play, X } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient'; @@ -34,7 +34,7 @@ export function AdminEditorAssetThumbnail({ }) { const thumbnailSource = resolveAdminAssetThumbnailSource(entry); const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility(); - const imageSrc = useAdminResolvedAssetUrl( + const { resolvedUrl: imageSrc } = useAdminResolvedAssetUrl( token, thumbnailSource.src, thumbnailSource.objectKey, @@ -142,12 +142,12 @@ function AdminEditorStandardAssetPreviewMedia({ }) { const isAudio = mediaKind === 'audio'; const isVideo = mediaKind === 'video'; - const mediaSrc = useAdminResolvedAssetUrl( + const { resolvedUrl: mediaSrc } = useAdminResolvedAssetUrl( token, entry.imageSrc, entry.objectKey, ); - const posterSrc = useAdminResolvedAssetUrl( + const { resolvedUrl: posterSrc } = useAdminResolvedAssetUrl( token, isVideo ? (entry.thumbnailSrc ?? '') : '', null, @@ -211,7 +211,9 @@ function AdminEditorImageSequenceFrame({ visible, loaded, token, + retryGeneration, onReady, + onFailed, }: { entry: AdminPreviewableEditorAsset; frame: AdminEditorImageSequenceFramePayload; @@ -219,13 +221,32 @@ function AdminEditorImageSequenceFrame({ visible: boolean; loaded: boolean; token: string; + retryGeneration: number; onReady: (frameKey: string) => void; + onFailed: (frameKey: string) => void; }) { - const resolvedUrl = useAdminResolvedAssetUrl( + const failureReportedRef = useRef(false); + const { resolvedUrl, hasError } = useAdminResolvedAssetUrl( token, frame.imageSrc, frame.objectKey, + true, + `${frameKey}:${retryGeneration}`, ); + const reportFailure = useCallback(() => { + if (failureReportedRef.current) { + return; + } + failureReportedRef.current = true; + onFailed(frameKey); + }, [frameKey, onFailed]); + + useEffect(() => { + if (hasError) { + reportFailure(); + } + }, [hasError, reportFailure]); + if (!resolvedUrl) { return null; } @@ -238,6 +259,7 @@ function AdminEditorImageSequenceFrame({ decoding="async" style={{ opacity: visible && loaded ? 1 : 0, transition: 'none' }} onLoad={() => onReady(frameKey)} + onError={reportFailure} /> ); } @@ -253,11 +275,17 @@ function AdminEditorImageSequencePreview({ durationMs: number; token: string; }) { - const frameItems = frames.map((frame, index) => ({ - frame, - index, - key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join(':'), - })); + const frameItems = useMemo( + () => + frames.map((frame, index) => ({ + frame, + index, + key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join( + ':', + ), + })), + [entry.assetId, frames], + ); const sequenceKey = frameItems.map((item) => item.key).join('|'); const firstFrameKey = frameItems[0]?.key ?? ''; const [frameIndex, setFrameIndex] = useState(0); @@ -265,17 +293,29 @@ function AdminEditorImageSequencePreview({ const [loadedFrameKeys, setLoadedFrameKeys] = useState>( () => new Set(), ); + const [failedFrameKeys, setFailedFrameKeys] = useState>( + () => new Set(), + ); + const [retryGeneration, setRetryGeneration] = useState(0); const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey); const currentFrameItem = frameItems[Math.min(frameIndex, frameItems.length - 1)]; const currentFrameKey = currentFrameItem?.key ?? ''; - const mountedFrameKeys = new Set([visibleFrameKey]); - for (let offset = 0; offset < Math.min(3, frameItems.length); offset += 1) { + const 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) { + if (item && !failedFrameKeys.has(item.key)) { mountedFrameKeys.add(item.key); } } + const availableFrameCount = frameItems.length - failedFrameKeys.size; const handleFrameReady = useCallback((frameKey: string) => { setLoadedFrameKeys((currentKeys) => { @@ -288,13 +328,51 @@ function AdminEditorImageSequencePreview({ }); }, []); + 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]); + useEffect(() => { setFrameIndex(0); setIsPlaying(true); setLoadedFrameKeys(new Set()); + setFailedFrameKeys(new Set()); setVisibleFrameKey(firstFrameKey); }, [firstFrameKey, sequenceKey]); + useEffect(() => { + if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) { + return; + } + const nextIndex = findNextAvailableFrameIndex( + frameItems, + failedFrameKeys, + frameIndex, + ); + if (nextIndex === null) { + setVisibleFrameKey(''); + setIsPlaying(false); + return; + } + setFrameIndex(nextIndex); + }, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]); + useEffect(() => { if (!currentFrameKey) { setVisibleFrameKey(''); @@ -307,15 +385,29 @@ function AdminEditorImageSequencePreview({ }, [currentFrameKey, loadedFrameKeys, visibleFrameKey]); useEffect(() => { - if (!isPlaying) { + if (!isPlaying || availableFrameCount < 2) { return undefined; } const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length); const timer = window.setInterval(() => { - setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length); + setFrameIndex( + (currentIndex) => + findNextAvailableFrameIndex( + frameItems, + failedFrameKeys, + currentIndex, + ) ?? currentIndex, + ); }, frameIntervalMs); return () => window.clearInterval(timer); - }, [durationMs, frames.length, isPlaying]); + }, [ + availableFrameCount, + durationMs, + failedFrameKeys, + frameItems, + frames.length, + isPlaying, + ]); return (
mountedFrameKeys.has(item.key)) .map((item) => ( ))}
@@ -349,11 +443,34 @@ function AdminEditorImageSequencePreview({ )} {`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`} + {failedFrameKeys.size ? ( + <> + {`${failedFrameKeys.size} 帧加载失败`} + + + ) : null}
); } +function findNextAvailableFrameIndex( + frameItems: ReadonlyArray<{ key: string }>, + failedFrameKeys: ReadonlySet, + currentIndex: number, +) { + for (let offset = 1; offset <= frameItems.length; offset += 1) { + const nextIndex = (currentIndex + offset) % frameItems.length; + const item = frameItems[nextIndex]; + if (item && !failedFrameKeys.has(item.key)) { + return nextIndex; + } + } + return null; +} + function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) { const mediaKind = resolveAdminAssetMediaKind(entry); if (mediaKind === 'image-sequence') { @@ -517,6 +634,7 @@ function useAdminResolvedAssetUrl( imageSrc: string | null | undefined, objectKey: string | null | undefined, enabled = true, + refreshKey: string | number | null = null, ) { const normalizedImageSrc = imageSrc?.trim() ?? ''; const normalizedObjectKey = normalizeAdminObjectKey(objectKey); @@ -528,18 +646,26 @@ function useAdminResolvedAssetUrl( const [resolvedImageSrc, setResolvedImageSrc] = useState( shouldResolve ? '' : normalizedImageSrc, ); + const [isResolving, setIsResolving] = useState(enabled && shouldResolve); + const [hasError, setHasError] = useState(false); useEffect(() => { if (!normalizedImageSrc && !normalizedObjectKey) { setResolvedImageSrc(''); + setIsResolving(false); + setHasError(false); return; } if (!shouldResolve) { setResolvedImageSrc(normalizedImageSrc); + setIsResolving(false); + setHasError(false); return; } if (!enabled) { setResolvedImageSrc(''); + setIsResolving(false); + setHasError(false); return; } @@ -548,6 +674,8 @@ function useAdminResolvedAssetUrl( let retryIndex = 0; const dispatchController = new AbortController(); setResolvedImageSrc(''); + setIsResolving(true); + setHasError(false); const resolveReadUrl = async () => { try { @@ -568,7 +696,10 @@ function useAdminResolvedAssetUrl( }, ); if (!cancelled) { - setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response)); + const nextUrl = resolveAdminAssetReadSignedUrl(response); + setResolvedImageSrc(nextUrl); + setIsResolving(false); + setHasError(!nextUrl); } } catch (error: unknown) { if (cancelled) { @@ -585,6 +716,8 @@ function useAdminResolvedAssetUrl( return; } setResolvedImageSrc(''); + setIsResolving(false); + setHasError(true); } }; @@ -602,11 +735,16 @@ function useAdminResolvedAssetUrl( normalizedImageSrc, normalizedLegacyPublicPath, normalizedObjectKey, + refreshKey, shouldResolve, token, ]); - return resolvedImageSrc; + return { + resolvedUrl: resolvedImageSrc, + isResolving, + hasError, + }; } async function waitForAdminAssetReadDispatch(signal: AbortSignal) { diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index cd0b63846..7e83b37bb 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -1177,8 +1177,28 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as expireSeconds: 300, }); + const failedFrame = dialog.querySelector( + '.admin-asset-query-sequence-frame', + ) as HTMLImageElement; + fireEvent.error(failedFrame); + expect(await within(dialog).findByText('1 帧加载失败')).toBeTruthy(); + await waitFor(() => { + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-animations/editor/source-1/task-1/frame03.png', + expireSeconds: 300, + }); + }); + const callsBeforeRetry = vi.mocked(getAdminAssetReadUrl).mock.calls.length; + await user.click( + within(dialog).getByRole('button', { name: '重试失败帧' }), + ); + await waitFor(() => { + expect(vi.mocked(getAdminAssetReadUrl).mock.calls.length).toBeGreaterThan( + callsBeforeRetry, + ); + }); expect( - within(dialog).getByRole('button', { name: '播放角色动作' }), + within(dialog).getByRole('button', { name: '暂停角色动作' }), ).toBeTruthy(); }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c4e5603fa..809f9345d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3913,7 +3913,7 @@ - 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。 - 原因:快照字段、展示 renderer 和私有对象授权是三道独立边界。公开 `imageSrc/objectKey` 只代表首帧,不能让前端自动获得完整帧集;顶层精选 exact grant 也不会自动覆盖其它帧对象。 -- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后也只挂载当前帧和有界预读窗口,避免再次制造换签突发;卡片 hover 与 focus 分别跟踪,只要任一状态仍成立就继续播放,系统请求 `prefers-reduced-motion` 时卡片和弹窗默认暂停,用户仍可在弹窗中手动播放。 +- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后也只挂载当前帧和有界预读窗口,避免再次制造换签突发;单帧换签或解码失败时跳过该帧、暂停全帧失败的序列并提供显式重试,不能长期显示空白或旧帧。卡片 hover 与 focus 分别跟踪,只要任一状态仍成立就继续播放,系统请求 `prefers-reduced-motion` 时卡片和弹窗默认暂停,用户仍可在弹窗中手动播放。 - 验证:模型 / 组件测试覆盖 4 / 5 / 6 秒动作、损坏序列不回退 PNG、后台两页共用播放器和未激活列表不逐帧请求;SpacetimeDB 测试覆盖主对象、每帧对象、无关对象、跨 owner 与取消展示后的授权撤销。真实浏览器和端到端验收由人工单独执行,不把 unit / component 结果写成 E2E PASS。 ## 可复用资源回填必须保持时间戳单调 diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 5c747035b..0f2f5b7dc 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -569,6 +569,18 @@ describe('CreationLandingView', () => { ), ).toHaveLength(3); + const failedModalFrame = document.querySelector( + '.creation-landing__showcase-current .creation-landing__image-sequence-frame', + ) as HTMLImageElement; + await user.click(screen.getByRole('button', { name: '暂停角色动作' })); + fireEvent.error(failedModalFrame); + expect(await screen.findByText('1 帧加载失败')).toBeTruthy(); + expect(await screen.findByText('2/5')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '重试失败帧' })); + await waitFor(() => { + expect(screen.queryByText('1 帧加载失败')).toBeNull(); + }); + await user.click(screen.getByRole('button', { name: '暂停角色动作' })); expect(screen.getByRole('button', { name: '播放角色动作' })).toBeTruthy(); setIntervalSpy.mockRestore(); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 876af7ca7..43b6f1583 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -1,10 +1,4 @@ -import { - Film, - Image as ImageIcon, - Music2, - Pause, - Play, -} from 'lucide-react'; +import { Film, Image as ImageIcon, Music2, Pause, Play } from 'lucide-react'; import { useCallback, useEffect, @@ -269,10 +263,7 @@ function getPreviewIcon(preview: ShowcaseAssetPreview) { if (preview.mediaType === 'audio') { return