Files
Genarrative/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts
T
k88936 6ee33dc561
Project CI / Repository checks (push) Successful in 1m4s
Project CI / Frontend tests (push) Successful in 2m9s
Project CI / Native shell tests (push) Successful in 2m38s
Project CI / Backend tests (push) Successful in 3m23s
fix: 首页「最近项目」缩略图与退出前视角不一致,出现偏移或裁切 (#111)
- 封面使用真实画布尺寸及最终 viewport.x/y/scale。
- 320×240 封面改为居中 cover 裁切,不再从左上角取景。
- 正确绘制画布背景色。

in canva:
![shotmd-1784874081.jpg](/attachments/4ac128cc-64c8-4a78-9e75-c35902049d3e)
![shotmd-1784876202.jpg](/attachments/6197259b-887e-4fd6-95b5-b722ec670a02)

in home:
![shotmd-1784874090.jpg](/attachments/241c7cd3-1eee-4f7b-96f2-4ca0268d2bee)
![shotmd-1784876211.jpg](/attachments/aa4200b6-bef0-4122-a797-ebc082b27a17)

因为原本的布局持久化可能有一些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>
2026-07-29 16:09:30 +08:00

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);
}