28e11c4718
新增项目封面 IndexedDB 本地缓存。 封面 Blob 生成后先写入本地缓存,上传失败仍可展示。 项目列表优先服务端封面,其次本地缓存,再回退画布图层。 补充封面兜底测试和项目记忆文档。
175 lines
5.2 KiB
TypeScript
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
|
|
);
|
|
}
|