6ee33dc561
- 封面使用真实画布尺寸及最终 viewport.x/y/scale。 - 320×240 封面改为居中 cover 裁切,不再从左上角取景。 - 正确绘制画布背景色。 in canva:   in home:   因为原本的布局持久化可能有一些bug,改动不相关,所以我回退到最开始的一版: - 不额外监听画布尺寸变化,给最小尺寸兜底,(大了比小了好) - 封面不要求强一致性,生成/上传时机仍然按照原来随布局持久化 优化了封面生成: - 改用webp格式 - 尽量利用图片缓存 最终生成封面从700ms加速到150ms以内, 加上必须的上传和确认150ms 和 布局持久化的时间相差不多,不是特别离谱的手速都能保证两者一致 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/111 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
159 lines
4.4 KiB
TypeScript
159 lines
4.4 KiB
TypeScript
import {
|
|
getSignedAssetReadUrl,
|
|
resolveAssetReadUrl,
|
|
shouldResolveAssetReadUrl,
|
|
} from '../../services/assetReadUrlService';
|
|
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
|
import {
|
|
normalizeProjectCoverBackgroundColor,
|
|
PROJECT_COVER_SNAPSHOT_SIZE,
|
|
type ProjectCoverDrawableLayer,
|
|
type ProjectCoverSnapshotViewportSize,
|
|
resolveProjectCoverDrawableLayers,
|
|
resolveProjectCoverPlacement,
|
|
} from './ImageCanvasProjectCoverSnapshotModel';
|
|
|
|
const PROJECT_COVER_IMAGE_LOAD_CONCURRENCY = 6;
|
|
const PROJECT_COVER_SNAPSHOT_MIME_TYPE = 'image/webp';
|
|
const PROJECT_COVER_SNAPSHOT_WEBP_QUALITY = 0.82;
|
|
|
|
type ProjectCoverDrawItem = {
|
|
layer: ProjectCoverDrawableLayer;
|
|
placement: ReturnType<typeof resolveProjectCoverPlacement>;
|
|
};
|
|
|
|
function placementIntersectsCover(
|
|
placement: ProjectCoverDrawItem['placement'],
|
|
) {
|
|
return (
|
|
placement.x < PROJECT_COVER_SNAPSHOT_SIZE.width &&
|
|
placement.y < PROJECT_COVER_SNAPSHOT_SIZE.height &&
|
|
placement.x + placement.width > 0 &&
|
|
placement.y + placement.height > 0
|
|
);
|
|
}
|
|
|
|
async function resolveProjectCoverImageUrl(layer: ProjectCoverDrawableLayer) {
|
|
if (layer.objectKey) {
|
|
return getSignedAssetReadUrl({ objectKey: layer.objectKey }, undefined, {
|
|
cacheVersion: layer.cacheVersion,
|
|
});
|
|
}
|
|
if (shouldResolveAssetReadUrl(layer.src)) {
|
|
return resolveAssetReadUrl(layer.src, {
|
|
refreshKey: layer.cacheVersion,
|
|
});
|
|
}
|
|
return layer.src;
|
|
}
|
|
|
|
async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) {
|
|
const src = await resolveProjectCoverImageUrl(layer);
|
|
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const image = new Image();
|
|
image.crossOrigin = 'anonymous';
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error('project cover image load failed'));
|
|
image.src = src;
|
|
});
|
|
return image;
|
|
}
|
|
|
|
async function loadProjectCoverDrawItems(items: ProjectCoverDrawItem[]) {
|
|
const loadedImages = new Array<HTMLImageElement | null>(items.length).fill(
|
|
null,
|
|
);
|
|
let nextIndex = 0;
|
|
const loadNext = async () => {
|
|
while (nextIndex < items.length) {
|
|
const index = nextIndex;
|
|
nextIndex += 1;
|
|
try {
|
|
const item = items[index];
|
|
if (item) {
|
|
loadedImages[index] = await loadProjectCoverImage(item.layer);
|
|
}
|
|
} catch {
|
|
// Individual layer load failures should not block other cover layers.
|
|
}
|
|
}
|
|
};
|
|
await Promise.all(
|
|
Array.from(
|
|
{ length: Math.min(PROJECT_COVER_IMAGE_LOAD_CONCURRENCY, items.length) },
|
|
() => loadNext(),
|
|
),
|
|
);
|
|
return loadedImages;
|
|
}
|
|
|
|
function canvasToBlob(canvas: HTMLCanvasElement) {
|
|
return new Promise<Blob | null>((resolve) => {
|
|
canvas.toBlob(
|
|
(blob) => resolve(blob),
|
|
PROJECT_COVER_SNAPSHOT_MIME_TYPE,
|
|
PROJECT_COVER_SNAPSHOT_WEBP_QUALITY,
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function createProjectCoverSnapshotBlob({
|
|
layers,
|
|
viewport,
|
|
viewportSize,
|
|
backgroundColor,
|
|
}: {
|
|
layers: readonly CanvasLayer[];
|
|
viewport: CanvasViewport;
|
|
viewportSize: ProjectCoverSnapshotViewportSize;
|
|
backgroundColor: string;
|
|
}) {
|
|
if (typeof document === 'undefined') {
|
|
return null;
|
|
}
|
|
const drawableLayers = resolveProjectCoverDrawableLayers(layers);
|
|
if (drawableLayers.length === 0) {
|
|
return null;
|
|
}
|
|
const drawItems = drawableLayers
|
|
.map((layer) => ({
|
|
layer,
|
|
placement: resolveProjectCoverPlacement(viewport, viewportSize, layer),
|
|
}))
|
|
.filter(({ placement }) => placementIntersectsCover(placement));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width;
|
|
canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height;
|
|
const context = canvas.getContext('2d');
|
|
if (!context || typeof canvas.toBlob !== 'function') {
|
|
return null;
|
|
}
|
|
context.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor);
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
if (drawItems.length === 0) {
|
|
return canvasToBlob(canvas);
|
|
}
|
|
|
|
const loadedImages = await loadProjectCoverDrawItems(drawItems);
|
|
let drawnLayerCount = 0;
|
|
drawItems.forEach(({ placement }, index) => {
|
|
const image = loadedImages[index];
|
|
if (!image) {
|
|
return;
|
|
}
|
|
context.drawImage(
|
|
image,
|
|
placement.x,
|
|
placement.y,
|
|
placement.width,
|
|
placement.height,
|
|
);
|
|
drawnLayerCount += 1;
|
|
});
|
|
if (drawnLayerCount === 0) {
|
|
return null;
|
|
}
|
|
return canvasToBlob(canvas);
|
|
}
|