Files
Genarrative/src/components/project/ProjectCanvasCover.tsx
T
kdletters 28e11c4718 修复项目封面本地兜底
新增项目封面 IndexedDB 本地缓存。

封面 Blob 生成后先写入本地缓存,上传失败仍可展示。

项目列表优先服务端封面,其次本地缓存,再回退画布图层。

补充封面兜底测试和项目记忆文档。
2026-06-25 21:59:05 +08:00

175 lines
5.2 KiB
TypeScript

import type {
EditorProjectLayerSnapshot,
EditorProjectResourceSnapshot,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import { PROJECT_COVER_SNAPSHOT_ASSET_KIND } from '../image-editor/ImageCanvasProjectCoverSnapshotModel';
const PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY =
'editor_project_cover_snapshot';
export const LOCAL_PROJECT_COVER_CACHE_ASSET_KIND = 'local-project-cover-cache';
type ProjectResourceSnakeKind = {
asset_kind?: string | null;
src?: string | null;
};
type ProjectCoverResourceCandidate = EditorProjectResourceSnapshot & {
imageSrc: string;
};
function stringFromUnknown(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function numberFromUnknown(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readProjectCoverSnapshotKind(resource: EditorProjectResourceSnapshot) {
const legacyResource = resource as ProjectResourceSnakeKind;
return (
resource.assetKind?.trim() ||
legacyResource.asset_kind?.trim() ||
''
).trim();
}
function readProjectCoverSnapshotImageSrc(
resource: EditorProjectResourceSnapshot,
) {
const legacyResource = resource as ProjectResourceSnakeKind;
return (
(typeof resource.imageSrc === 'string' ? resource.imageSrc.trim() : '') ||
(typeof legacyResource.src === 'string' ? legacyResource.src.trim() : '')
);
}
function hasProjectCoverSnapshotSource(
resource: EditorProjectResourceSnapshot,
) {
const resourceObjectKey = resource.objectKey?.trim() || '';
const resourceImageSrc = readProjectCoverSnapshotImageSrc(resource);
return Boolean(resourceObjectKey || resourceImageSrc);
}
function hasProjectCoverSource(
resource: EditorProjectResourceSnapshot,
): resource is ProjectCoverResourceCandidate {
return hasProjectCoverSnapshotSource(resource);
}
function isDedicatedProjectCoverSnapshot(
resource: EditorProjectResourceSnapshot,
) {
return [
PROJECT_COVER_SNAPSHOT_ASSET_KIND,
PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY,
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
].includes(readProjectCoverSnapshotKind(resource));
}
function isLocalProjectCoverCacheResource(
resource: EditorProjectResourceSnapshot,
) {
return (
readProjectCoverSnapshotKind(resource) ===
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND
);
}
function projectCoverSnapshotObjectTimestamp(
resource: EditorProjectResourceSnapshot,
) {
const objectKey = resource.objectKey?.trim() || '';
const imageSrc = readProjectCoverSnapshotImageSrc(resource);
const snapshotPath = objectKey || imageSrc;
const timestampSegment = snapshotPath.match(
/\/project-covers\/[^/]+\/(\d{10,})\//u,
)?.[1];
const timestampMs = Number(timestampSegment);
return Number.isFinite(timestampMs) ? timestampMs : 0;
}
function projectCoverSnapshotSortScore(
resource: EditorProjectResourceSnapshot,
) {
const objectTimestamp = projectCoverSnapshotObjectTimestamp(resource);
if (objectTimestamp > 0) {
return objectTimestamp;
}
const updatedAt = resource.updatedAt ?? resource.createdAt ?? '';
const updatedAtMs = new Date(updatedAt).getTime();
return Number.isFinite(updatedAtMs) ? updatedAtMs : 0;
}
export function resolveProjectCoverSnapshotResource(
project: EditorProjectSnapshot,
) {
return (
[...project.resources]
.filter(
(resource) =>
isDedicatedProjectCoverSnapshot(resource) &&
hasProjectCoverSource(resource),
)
.sort((left, right) => {
if (
isLocalProjectCoverCacheResource(left) !==
isLocalProjectCoverCacheResource(right)
) {
return isLocalProjectCoverCacheResource(left) ? 1 : -1;
}
const byUpdatedAt =
projectCoverSnapshotSortScore(right) -
projectCoverSnapshotSortScore(left);
return byUpdatedAt || right.resourceId.localeCompare(left.resourceId);
})[0] ?? null
);
}
function readLayerResourceId(layer: EditorProjectLayerSnapshot) {
return stringFromUnknown(layer.resourceId);
}
function isLayerVisible(layer: EditorProjectLayerSnapshot) {
return layer.hidden !== true && layer.mediaType !== 'audio';
}
function layerCoverSortScore(layer: EditorProjectLayerSnapshot, index: number) {
return {
zIndex: numberFromUnknown(layer.zIndex, 0),
index,
};
}
export function resolveProjectCoverResource(project: EditorProjectSnapshot) {
const snapshot = resolveProjectCoverSnapshotResource(project);
if (snapshot) {
return snapshot;
}
const resourcesById = new Map(
project.resources
.filter((resource) => !isDedicatedProjectCoverSnapshot(resource))
.filter(hasProjectCoverSource)
.map((resource) => [resource.resourceId, resource]),
);
return (
project.layers
.map((layer, index) => ({ layer, index }))
.filter(({ layer }) => isLayerVisible(layer))
.flatMap(({ layer, index }) => {
const resource = resourcesById.get(readLayerResourceId(layer));
return resource
? [{ resource, score: layerCoverSortScore(layer, index) }]
: [];
})
.sort((left, right) => {
const byZIndex = right.score.zIndex - left.score.zIndex;
return byZIndex || right.score.index - left.score.index;
})[0]?.resource ?? null
);
}