Files
Genarrative/apps/admin-web/src/components/AdminEditorAssetMedia.tsx
T
kdletters 8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
后台公共组件收口到共享组件库并统一列表实现
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出
- AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表)
- AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序
- AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它
- AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间
- 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列
- 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable
- 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心
- 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染
- docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑
- 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
2026-09-29 22:39:41 +08:00

1156 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 { AdminDialog } from '@genarrative/shared/components';
import { Pause, Play } 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 (
<AdminDialog
ariaLabel="素材预览"
closeLabel="关闭素材预览"
description={entry.assetId}
panelClassName="admin-asset-query-preview-dialog"
title={entry.label || entry.assetId}
onClose={onClose}
>
{sourceEntry ? (
<div className="admin-asset-query-source-preview">
<h4>原图</h4>
<AdminEditorAssetPreviewMedia entry={sourceEntry} token={token} />
</div>
) : null}
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
</AdminDialog>
);
}
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() : '';
}