Files
Genarrative/apps/admin-web/src/components/AdminEditorAssetMedia.tsx
T
k88936 a03f13d0a5
Project CI / Repository checks (push) Successful in 1m28s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Backend tests (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 13m58s
Fix/动作 前端展示下载+后端数据结构+精选 的问题 (#117)
原问题

```
历史角色动作是一个兼容例外:
后端允许它没有 editor_project_resource 资源行,只要布局自身保存了完整图片序列帧。
```

- 把生成动作的原视频asstetkind改为 video, 只把图片序列作为action
- db asset新增字段image_sequence_frames_json image_seq_duration_ms等, (原来动作的这些数据存在generation-input中,并不合适)
修改了externaljob,把这部分数据正确填写到数据库中。
- 为避免到处fallback,做了数据库迁移, 脚本: `scripts/spacetime-normalize-editor-character-actions.mjs`   手动进行过画布+素材库中动作序列帧+原始视频 迁移的测试

- 移除preview_video_path字段,  reason:
>preview video
    → separate resource/asset with assetKind="video"
  final transparent action
    → assetKind="character-animation"
    → source_resource_id points to the preview-video resource
    → image_sequence_frames_json contains the actual playable result
>
> So preview_video_path on the final action duplicates the source video resource’s image_src.
- 移除每个frame的index字段 原因: 这个只用于后端内部处理时有一个并发请求, 每个赋一个index方便收集, 后续没有再用到,且与数组本身重复

- 清除副产品preview video的generation_input_json,  因为会影响改造功能, 迁移后预览视频不提供改造(参数), 只有序列帧动作有改造

仍存在的共性问题: #134

- 导出:
下载改为完整序列帧, 封面不再作为fallback, 部分帧读取失败时行为:仍生成 ZIP,并记录失败帧, 不变

画布放置:
before:
![shotmd-1785207959-compressed.webp](/attachments/e56454f0-49a3-4a8a-951f-c21960975f99)
after:
![shotmd-1785231365-compressed.webp](/attachments/886f07df-c25b-4ecc-acc9-160e1795a991)
精选模块: 主页展示, 审核部分UI:
![shotmd-1785500046-compressed.webp](/attachments/9ec3b679-0045-494a-9f20-134f7e73fecf)
![shotmd-1785499780-compressed.webp](/attachments/42396e63-5f26-4853-a367-0f3c15111add)
这两处序列帧们的加载设计为惰式的, 只有hover和单独preview才会全部加载

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/117
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-06 16:57:38 +08:00

1148 lines
32 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;
}
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;
}) {
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>
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
</section>
</div>
);
}
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() : '';
}