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

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