Files
Genarrative/apps/admin-web/src/components/AdminEditorAssetMedia.tsx
T
kdletters c5200c7d45
Project CI / Backend tests (push) Successful in 8m31s
Project CI / Repository checks (push) Successful in 14m14s
Project CI / Frontend tests (push) Successful in 13m2s
Project CI / Native shell tests (push) Successful in 21m30s
补齐共享 UI 组件库与展示页交互 (#202)
## 变更内容

- 新增基于 shadcn open-code 模式的共享 UI canonical 组件、样式与导出。
- 新增 `/components` 共享组件展示页,并覆盖平台组件、Token、状态和移动端布局。
- 补齐平台展示页的筛选、排序、上传预览、标签、开关和异步状态交互。
- 修复排序导致预览主题变化、筛选弹窗误关闭和入口状态不更新的问题。
- 同步网站与客户端构建别名、依赖、路由测试和项目文档。

## 验证

- `npm run test -- src/components/shared-components/SharedComponentsShowcasePage.test.tsx`
- `npm run typecheck`
- `npm run check:encoding`
- `npm run build:raw`
- `git diff --check`
- pre-push 门禁通过

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/202
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-08-31 14:10:44 +08:00

1168 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Pause, Play, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
parseSignedReadUrlExpiresAtMs,
SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS,
} from '../../../../packages/shared/src/utils/signedReadUrlCache';
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;
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
let adminAssetReadDispatchTail = Promise.resolve();
export interface AdminPreviewableEditorAsset {
assetId: string;
label: string;
imageSrc: string;
objectKey?: string | null;
assetKind?: string | null;
thumbnailSrc?: string | null;
imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null;
imageSequenceDurationMs?: number | null;
sourceResourceId?: string | null;
sourceImageSrc?: string | null;
sourceObjectKey?: string | null;
sourceAssetObjectId?: string | null;
sourceLabel?: string | null;
}
export function AdminEditorAssetThumbnail({
entry,
token,
altPrefix = '素材',
}: {
entry: AdminPreviewableEditorAsset;
token: string;
altPrefix?: string;
}) {
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility();
const { resolvedUrl: imageSrc } = useAdminResolvedAssetUrl(
token,
thumbnailSource.src,
thumbnailSource.objectKey,
shouldLoad,
);
const alt = `${altPrefix}:${entry.label || entry.assetId}`;
return imageSrc ? (
<img
ref={observeElement}
alt={alt}
className="admin-asset-query-thumb"
src={imageSrc}
/>
) : (
<div
ref={observeElement}
className="admin-asset-query-thumb admin-asset-query-thumb-placeholder"
/>
);
}
export function AdminEditorAssetPreviewDialog({
entry,
token,
onClose,
}: {
entry: AdminPreviewableEditorAsset;
token: string;
onClose: () => void;
}) {
const sourceEntry = entry.sourceImageSrc?.trim()
? {
assetId: entry.sourceResourceId ?? `${entry.assetId}-source`,
label: entry.sourceLabel?.trim() || '原图',
imageSrc: entry.sourceImageSrc,
objectKey: entry.sourceObjectKey,
}
: null;
return (
<div className="admin-confirm-backdrop" role="presentation">
<section
aria-label="素材预览"
className="admin-detail-panel admin-asset-query-preview-dialog"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h3>{entry.label || entry.assetId}</h3>
<span>{entry.assetId}</span>
</div>
<button
aria-label="关闭素材预览"
className="admin-ghost-button"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
{sourceEntry ? (
<div className="admin-asset-query-source-preview">
<h4>原图</h4>
<AdminEditorAssetPreviewMedia entry={sourceEntry} token={token} />
</div>
) : null}
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
</section>
</div>
);
}
export function AdminEditorAssetPreviewMedia({
entry,
token,
}: {
entry: AdminPreviewableEditorAsset;
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 { resolvedUrl: mediaSrc } = useAdminResolvedAssetUrl(
token,
entry.imageSrc,
entry.objectKey,
);
const { resolvedUrl: posterSrc } = useAdminResolvedAssetUrl(
token,
isVideo ? (entry.thumbnailSrc ?? '') : '',
null,
);
const label = entry.label || entry.assetId;
if (isAudio) {
return (
<div className="admin-asset-query-preview-audio">
<img
alt={`音频封面:${label}`}
className="admin-asset-query-preview-cover"
src={AUDIO_ASSET_COVER_SRC}
/>
{mediaSrc ? (
<audio
aria-label={`音频预览:${label}`}
className="admin-asset-query-preview-player"
controls
preload="metadata"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
)}
</div>
);
}
if (isVideo) {
return mediaSrc ? (
<video
aria-label={`视频预览:${label}`}
className="admin-asset-query-preview-media"
controls
playsInline
poster={posterSrc || undefined}
preload="metadata"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
);
}
return mediaSrc ? (
<img
alt={`图片预览:${label}`}
className="admin-asset-query-preview-media"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
);
}
function AdminEditorImageSequenceFrame({
entry,
frameKey,
resolvedUrl,
visible,
ready,
onReady,
onFailed,
}: {
entry: AdminPreviewableEditorAsset;
frameKey: string;
resolvedUrl: string;
visible: boolean;
ready: boolean;
onReady: (frameKey: string) => void;
onFailed: (frameKey: string) => void;
}) {
const failureReportedRef = useRef(false);
const reportFailure = useCallback(() => {
if (failureReportedRef.current) {
return;
}
failureReportedRef.current = true;
onFailed(frameKey);
}, [frameKey, onFailed]);
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 && ready ? 1 : 0, transition: 'none' }}
onLoad={() => onReady(frameKey)}
onError={reportFailure}
/>
);
}
function AdminEditorImageSequencePreview({
entry,
frames,
durationMs,
token,
}: {
entry: AdminPreviewableEditorAsset;
frames: AdminEditorImageSequenceFramePayload[];
durationMs: number;
token: string;
}) {
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 frameCacheOwnerKey = `${token}\u0000${sequenceKey}`;
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const {
frameCache,
resolveFrames,
markFrameReady,
markFrameFailed,
retryFailedFrames: clearFailedFrameCache,
} = useAdminImageSequenceFrameCache(
token,
frameCacheOwnerKey,
frameItems.length,
);
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 = useMemo(() => {
const nextMountedFrameKeys = new Set<string>();
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 retryFailedFrames = useCallback(() => {
setFrameIndex(0);
setIsPlaying(true);
clearFailedFrameCache();
}, [clearFailedFrameCache]);
useEffect(() => {
setFrameIndex(0);
setIsPlaying(true);
setVisibleFrameKey(firstFrameKey);
}, [firstFrameKey, sequenceKey]);
useEffect(() => {
resolveFrames(mountedFrameItems);
}, [mountedFrameItems, resolveFrames]);
useEffect(() => {
const refreshAtMs = mountedFrameItems.reduce(
(earliest, item) => {
const cached = frameCache.get(item.key);
if (
!cached ||
cached.expiresAtMs === null ||
cached.status === 'resolving' ||
cached.status === 'refreshing' ||
cached.status === 'failed'
) {
return earliest;
}
const candidate = cached.nextRefreshAtMs;
if (candidate === null) {
return earliest;
}
return earliest === null || candidate < earliest ? candidate : earliest;
},
null as number | null,
);
if (refreshAtMs === null) {
return undefined;
}
const timer = window.setTimeout(
() => resolveFrames(mountedFrameItems),
Math.min(2_147_483_647, Math.max(0, refreshAtMs - Date.now())),
);
return () => window.clearTimeout(timer);
}, [frameCache, mountedFrameItems, resolveFrames]);
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('');
return;
}
if (
!isAdminImageSequenceFrameReady(frameCache.get(currentFrameKey)) &&
visibleFrameKey
) {
return;
}
setVisibleFrameKey(currentFrameKey);
}, [currentFrameKey, frameCache, visibleFrameKey]);
useEffect(() => {
if (!isPlaying || availableFrameCount < 2) {
return undefined;
}
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
const timer = window.setInterval(() => {
setFrameIndex(
(currentIndex) =>
findNextAvailableFrameIndex(
frameItems,
failedFrameKeys,
currentIndex,
) ?? currentIndex,
);
}, frameIntervalMs);
return () => window.clearInterval(timer);
}, [
availableFrameCount,
durationMs,
failedFrameKeys,
frameItems,
frames.length,
isPlaying,
]);
return (
<div
aria-label={`角色动作预览:${entry.label || entry.assetId}`}
className="admin-asset-query-preview-media admin-asset-query-sequence-preview"
>
{mountedFrameItems.map((item) => {
const cachedFrame = frameCache.get(item.key);
return (
<AdminEditorImageSequenceFrame
key={item.key}
entry={entry}
frameKey={item.key}
resolvedUrl={cachedFrame?.resolvedUrl ?? ''}
visible={item.key === visibleFrameKey}
ready={isAdminImageSequenceFrameReady(cachedFrame)}
onReady={markFrameReady}
onFailed={markFrameFailed}
/>
);
})}
<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>
{failedFrameKeys.size ? (
<>
<span aria-live="polite">{`${failedFrameKeys.size} 帧加载失败`}</span>
<button type="button" onClick={retryFailedFrames}>
重试失败帧
</button>
</>
) : null}
</div>
</div>
);
}
type AdminImageSequenceFrameItem = {
frame: AdminEditorImageSequenceFramePayload;
index: number;
key: string;
};
type AdminImageSequenceFrameCacheEntry = {
expiresAtMs: number | null;
nextRefreshAtMs: number | null;
refreshRetryIndex: number;
resolvedUrl: string;
status: 'resolving' | 'refreshing' | 'loading' | 'ready' | 'failed';
};
type AdminImageSequenceFrameCacheState = {
ownerKey: string;
entries: ReadonlyMap<string, AdminImageSequenceFrameCacheEntry>;
};
const EMPTY_ADMIN_IMAGE_SEQUENCE_FRAME_CACHE = new Map<
string,
AdminImageSequenceFrameCacheEntry
>();
function useAdminImageSequenceFrameCache(
token: string,
ownerKey: string,
maximumEntryCount: number,
) {
const [cacheState, setCacheState] =
useState<AdminImageSequenceFrameCacheState>(() => ({
ownerKey,
entries: new Map(),
}));
const resolutionControllersRef = useRef(new Map<string, AbortController>());
const frameCache =
cacheState.ownerKey === ownerKey
? cacheState.entries
: EMPTY_ADMIN_IMAGE_SEQUENCE_FRAME_CACHE;
useEffect(() => {
const resolutionControllers = resolutionControllersRef.current;
resolutionControllers.forEach((controller) => controller.abort());
resolutionControllers.clear();
setCacheState({ ownerKey, entries: new Map() });
return () => {
resolutionControllers.forEach((controller) => controller.abort());
resolutionControllers.clear();
};
}, [ownerKey]);
const updateFrameCacheEntry = useCallback(
(
frameKey: string,
update: (
current: AdminImageSequenceFrameCacheEntry | undefined,
) => AdminImageSequenceFrameCacheEntry | null,
) => {
setCacheState((currentState) => {
const currentEntries =
currentState.ownerKey === ownerKey
? currentState.entries
: new Map<string, AdminImageSequenceFrameCacheEntry>();
const nextEntry = update(currentEntries.get(frameKey));
if (nextEntry === currentEntries.get(frameKey)) {
return currentState;
}
const nextEntries = new Map(currentEntries);
if (nextEntry) {
if (
!nextEntries.has(frameKey) &&
nextEntries.size >= maximumEntryCount
) {
return currentState;
}
nextEntries.set(frameKey, nextEntry);
} else {
nextEntries.delete(frameKey);
}
return { ownerKey, entries: nextEntries };
});
},
[maximumEntryCount, ownerKey],
);
const resolveFrames = useCallback(
(frameItemsToResolve: ReadonlyArray<AdminImageSequenceFrameItem>) => {
for (const item of frameItemsToResolve) {
const cached = frameCache.get(item.key);
if (resolutionControllersRef.current.has(item.key)) {
continue;
}
if (cached && shouldReuseAdminImageSequenceFrameCacheEntry(cached)) {
continue;
}
const refreshReadyFrame = isAdminImageSequenceFrameReady(cached);
const controller = new AbortController();
resolutionControllersRef.current.set(item.key, controller);
updateFrameCacheEntry(item.key, (current) =>
refreshReadyFrame && current?.resolvedUrl
? { ...current, status: 'refreshing' }
: {
expiresAtMs: null,
nextRefreshAtMs: null,
refreshRetryIndex: 0,
resolvedUrl: '',
status: 'resolving',
},
);
void resolveAdminAssetUrl(
token,
item.frame.imageSrc,
item.frame.objectKey,
controller.signal,
)
.then((resolved) => {
updateFrameCacheEntry(item.key, () => ({
...resolved,
nextRefreshAtMs:
resolved.expiresAtMs === null
? null
: resolved.expiresAtMs -
SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS,
refreshRetryIndex: 0,
status: refreshReadyFrame ? 'refreshing' : 'loading',
}));
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
updateFrameCacheEntry(item.key, (current) => {
if (!isAdminImageSequenceFrameUrlUsable(current)) {
return {
expiresAtMs: null,
nextRefreshAtMs: null,
refreshRetryIndex: 0,
resolvedUrl: '',
status: 'failed',
};
}
const retryDelay =
isAdminApiError(error) && error.status === 429
? undefined
: ADMIN_ASSET_READ_RETRY_DELAYS_MS[
current.refreshRetryIndex
];
return {
...current,
nextRefreshAtMs:
typeof retryDelay === 'number'
? Math.min(
current.expiresAtMs ?? Number.POSITIVE_INFINITY,
Date.now() + retryDelay,
)
: current.expiresAtMs,
refreshRetryIndex:
typeof retryDelay === 'number'
? current.refreshRetryIndex + 1
: current.refreshRetryIndex,
status: 'ready',
};
});
}
})
.finally(() => {
if (resolutionControllersRef.current.get(item.key) === controller) {
resolutionControllersRef.current.delete(item.key);
}
});
}
},
[frameCache, token, updateFrameCacheEntry],
);
const markFrameReady = useCallback(
(frameKey: string) => {
updateFrameCacheEntry(frameKey, (current) =>
current?.resolvedUrl
? { ...current, status: 'ready' }
: (current ?? null),
);
},
[updateFrameCacheEntry],
);
const markFrameFailed = useCallback(
(frameKey: string) => {
resolutionControllersRef.current.get(frameKey)?.abort();
resolutionControllersRef.current.delete(frameKey);
updateFrameCacheEntry(frameKey, () => ({
expiresAtMs: null,
nextRefreshAtMs: null,
refreshRetryIndex: 0,
resolvedUrl: '',
status: 'failed',
}));
},
[updateFrameCacheEntry],
);
const retryFailedFrames = useCallback(() => {
setCacheState((currentState) => {
if (currentState.ownerKey !== ownerKey) {
return { ownerKey, entries: new Map() };
}
const nextEntries = new Map(
Array.from(currentState.entries).filter(
([, cached]) => cached.status !== 'failed',
),
);
return { ownerKey, entries: nextEntries };
});
}, [ownerKey]);
return {
frameCache,
resolveFrames,
markFrameReady,
markFrameFailed,
retryFailedFrames,
};
}
function isAdminImageSequenceFrameReady(
cached: AdminImageSequenceFrameCacheEntry | undefined,
) {
return cached?.status === 'ready' || cached?.status === 'refreshing';
}
function shouldReuseAdminImageSequenceFrameCacheEntry(
cached: AdminImageSequenceFrameCacheEntry,
) {
if (cached.status === 'failed') {
return false;
}
return cached.nextRefreshAtMs === null || cached.nextRefreshAtMs > Date.now();
}
function isAdminImageSequenceFrameUrlUsable(
cached: AdminImageSequenceFrameCacheEntry | undefined,
): cached is AdminImageSequenceFrameCacheEntry {
return Boolean(
cached?.resolvedUrl &&
(cached.expiresAtMs === null || cached.expiresAtMs > Date.now()),
);
}
function findNextAvailableFrameIndex(
frameItems: ReadonlyArray<{ key: string }>,
failedFrameKeys: ReadonlySet<string>,
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') {
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 };
}
if (mediaKind === 'video') {
return { src: entry.thumbnailSrc || '', objectKey: null };
}
if (entry.thumbnailSrc?.trim()) {
return {
src: entry.thumbnailSrc,
objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc)
? entry.objectKey
: null,
};
}
return {
src: entry.imageSrc,
objectKey: entry.objectKey,
};
}
function useAdminAssetThumbnailVisibility() {
const [element, setElement] = useState<HTMLElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(false);
const observeElement = useCallback((nextElement: HTMLElement | null) => {
setElement(nextElement);
}, []);
useEffect(() => {
if (shouldLoad || !element) {
return;
}
if (typeof IntersectionObserver === 'undefined') {
setShouldLoad(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShouldLoad(true);
observer.disconnect();
}
},
{ rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN },
);
observer.observe(element);
return () => observer.disconnect();
}, [element, shouldLoad]);
return { observeElement, shouldLoad };
}
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;
}
if (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
assetKind === 'editor_uploaded_audio'
) {
return 'audio';
}
if (
assetKind === 'video' ||
assetKind === 'editor_video' ||
assetKind === 'editor-video' ||
assetKind === 'editor_uploaded_video'
) {
return 'video';
}
return 'image';
}
function resolveAdminAssetMediaKindFromPath(
value: string,
): AdminAssetMediaKind | null {
const normalizedValue = value.trim();
if (/^data:image\//iu.test(normalizedValue)) {
return 'image';
}
if (/^data:audio\//iu.test(normalizedValue)) {
return 'audio';
}
if (/^data:video\//iu.test(normalizedValue)) {
return 'video';
}
if (
/\.(?:avif|bmp|gif|jpe?g|png|svg|webp)(?:$|[?#])/iu.test(normalizedValue)
) {
return 'image';
}
if (/\.(?:aac|flac|m4a|mp3|ogg|opus|wav)(?:$|[?#])/iu.test(normalizedValue)) {
return 'audio';
}
if (/\.(?:m4v|mov|mp4|ogv|webm)(?:$|[?#])/iu.test(normalizedValue)) {
return 'video';
}
return null;
}
function useAdminResolvedAssetUrl(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
enabled = true,
refreshKey: string | number | null = null,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
? normalizedImageSrc
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
const shouldResolve =
Boolean(normalizedObjectKey) || Boolean(normalizedLegacyPublicPath);
const [resolvedImageSrc, setResolvedImageSrc] = useState(
shouldResolve ? '' : normalizedImageSrc,
);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (!normalizedImageSrc && !normalizedObjectKey) {
setResolvedImageSrc('');
setHasError(false);
return;
}
if (!shouldResolve) {
setResolvedImageSrc(normalizedImageSrc);
setHasError(false);
return;
}
if (!enabled) {
setResolvedImageSrc('');
setHasError(false);
return;
}
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryIndex = 0;
const dispatchController = new AbortController();
setResolvedImageSrc('');
setHasError(false);
const resolveReadUrl = async () => {
try {
await waitForAdminAssetReadDispatch(dispatchController.signal);
if (cancelled) {
return;
}
const response = await getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedLegacyPublicPath,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
},
);
if (!cancelled) {
const nextUrl = resolveAdminAssetReadSignedUrl(response);
setResolvedImageSrc(nextUrl);
setHasError(!nextUrl);
}
} catch (error: unknown) {
if (cancelled) {
return;
}
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
if (
isAdminApiError(error) &&
error.status === 429 &&
typeof retryDelay === 'number'
) {
retryIndex += 1;
retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay);
return;
}
setResolvedImageSrc('');
setHasError(true);
}
};
void resolveReadUrl();
return () => {
cancelled = true;
dispatchController.abort();
if (retryTimer !== null) {
clearTimeout(retryTimer);
}
};
}, [
enabled,
normalizedImageSrc,
normalizedLegacyPublicPath,
normalizedObjectKey,
refreshKey,
shouldResolve,
token,
]);
return {
resolvedUrl: resolvedImageSrc,
hasError,
};
}
async function resolveAdminAssetUrl(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
signal: AbortSignal,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
? normalizedImageSrc
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
if (!normalizedObjectKey && !normalizedLegacyPublicPath) {
return {
expiresAtMs: null,
resolvedUrl: normalizedImageSrc,
};
}
let retryIndex = 0;
while (!signal.aborted) {
try {
await waitForAdminAssetReadDispatch(signal);
const response = await getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedLegacyPublicPath,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
},
);
if (signal.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
const read = response.read ?? response;
const resolvedUrl = resolveAdminAssetReadSignedUrl(response);
const expiresAt =
typeof read.expiresAt === 'string' ? read.expiresAt.trim() : '';
const expiresAtMs = parseSignedReadUrlExpiresAtMs(expiresAt);
if (!resolvedUrl) {
throw new Error('素材读取地址为空');
}
if (expiresAtMs <= Date.now()) {
throw new Error('素材读取地址过期时间无效');
}
return { expiresAtMs, resolvedUrl };
} catch (error: unknown) {
if (signal.aborted) {
throw error;
}
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
if (
isAdminApiError(error) &&
error.status === 429 &&
typeof retryDelay === 'number'
) {
retryIndex += 1;
await waitForAdminAssetReadDelay(retryDelay, signal);
continue;
}
throw error;
}
}
throw new DOMException('The operation was aborted.', 'AbortError');
}
async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
const dispatch = adminAssetReadDispatchTail.then(
() => waitForAdminAssetReadDispatchSpacing(signal),
() => waitForAdminAssetReadDispatchSpacing(signal),
);
adminAssetReadDispatchTail = dispatch.catch(() => undefined);
await dispatch;
}
async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) {
await waitForAdminAssetReadDelay(
ADMIN_ASSET_READ_DISPATCH_SPACING_MS,
signal,
);
}
async function waitForAdminAssetReadDelay(
delayMs: number,
signal: AbortSignal,
) {
if (signal.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', handleAbort);
resolve();
}, delayMs);
function handleAbort() {
clearTimeout(timer);
reject(new DOMException('The operation was aborted.', 'AbortError'));
}
signal.addEventListener('abort', handleAbort, { once: true });
});
}
function normalizeAdminObjectKey(value: string | null | undefined) {
return value?.trim().replace(/^\/+/u, '') ?? '';
}
function adminAssetPathsMatch(left: string, right: string) {
return (
left.trim().replace(/^\/+|[?#].*$/gu, '') ===
right.trim().replace(/^\/+|[?#].*$/gu, '')
);
}
function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
function resolveAdminGeneratedLegacyPathFromUrl(value: string) {
try {
const parsedUrl = new URL(value);
if (
parsedUrl.protocol !== 'https:' ||
!/^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(parsedUrl.hostname)
) {
return '';
}
const legacyPublicPath = decodeURIComponent(parsedUrl.pathname);
return isGeneratedLegacyPath(legacyPublicPath) ? legacyPublicPath : '';
} catch {
return '';
}
}
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
const read = response.read ?? response;
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
}