Files
Genarrative/src/services/match3dSpritesheetParser.ts
高物 ae014ac881 Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
2026-05-22 03:06:41 +08:00

353 lines
8.5 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 { readAssetBytes } from './assetReadUrlService';
export type Match3DSpritesheetRegion = {
label: string;
x: number;
y: number;
width: number;
height: number;
};
export type Match3DItemSpritesheetViewRegion = Match3DSpritesheetRegion & {
itemIndex: number;
itemName: string;
viewIndex: number;
};
export type DetectMatch3DSpritesheetRegionsInput = {
alpha: ArrayLike<number>;
width: number;
height: number;
labels?: readonly string[];
minArea?: number;
alphaThreshold?: number;
};
export type Match3DDecodedSpritesheetRegion = Match3DSpritesheetRegion & {
imageSrc: string;
sheetWidth: number;
sheetHeight: number;
};
export type LoadMatch3DSpritesheetAssetRegionsInput = {
source: string;
labels?: readonly string[];
minArea?: number;
alphaThreshold?: number;
maxRegions?: number;
signal?: AbortSignal;
};
type Match3DDetectedComponent = Omit<Match3DSpritesheetRegion, 'label'> & {
area: number;
};
/**
* 中文注释AI spritesheet 只保证透明背景,不保证固定坐标;运行态和编辑器统一按 alpha 连通域识别独立素材矩形。
*/
export function detectMatch3DSpritesheetRegions({
alpha,
width,
height,
labels = [],
minArea = 1,
alphaThreshold = 0,
}: DetectMatch3DSpritesheetRegionsInput): Match3DSpritesheetRegion[] {
const pixelCount = width * height;
if (width <= 0 || height <= 0 || alpha.length < pixelCount) {
return [];
}
const visited = new Uint8Array(pixelCount);
const components: Match3DDetectedComponent[] = [];
for (let start = 0; start < pixelCount; start += 1) {
if (visited[start] || (alpha[start] ?? 0) <= alphaThreshold) {
continue;
}
const component = floodFillMatch3DSpritesheetComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
});
if (component.area >= minArea) {
components.push(component);
}
}
return components
.sort((left, right) => left.y - right.y || left.x - right.x)
.map((component, index) => ({
label: labels[index] ?? `素材${index + 1}`,
x: component.x,
y: component.y,
width: component.width,
height: component.height,
}));
}
export function buildMatch3DItemSpritesheetViewRegions<
Region extends Match3DSpritesheetRegion,
>(
regions: readonly Region[],
itemNames: readonly string[],
): Array<{
itemIndex: number;
itemName: string;
regions: Region[];
}> {
if (regions.length <= 0 || itemNames.length <= 0) {
return [];
}
const itemCount = Math.min(20, Math.floor(regions.length / 5));
return Array.from({ length: itemCount }, (_, itemIndex) => {
const itemName = itemNames[itemIndex]?.trim() || `物品${itemIndex + 1}`;
return {
itemIndex,
itemName,
regions: regions.slice(itemIndex * 5, itemIndex * 5 + 5).map(
(region, viewIndex): Region => ({
...region,
label: `${itemName}-形态${viewIndex + 1}`,
}) as Region,
),
};
});
}
export async function loadMatch3DSpritesheetAssetRegions({
source,
labels = [],
minArea = 16,
alphaThreshold = 8,
maxRegions,
signal,
}: LoadMatch3DSpritesheetAssetRegionsInput): Promise<
Match3DDecodedSpritesheetRegion[]
> {
const decoded = await decodeMatch3DSpritesheetImage(source, signal);
if (signal?.aborted) {
throw new DOMException('spritesheet 读取已取消', 'AbortError');
}
const regions = detectMatch3DSpritesheetRegions({
alpha: decoded.alpha,
width: decoded.width,
height: decoded.height,
labels,
minArea,
alphaThreshold,
}).slice(0, maxRegions ?? Number.POSITIVE_INFINITY);
return regions.map((region) => ({
...region,
imageSrc: cropMatch3DSpritesheetRegionToDataUrl(decoded.image, region),
sheetWidth: decoded.width,
sheetHeight: decoded.height,
}));
}
function loadMatch3DSpritesheetImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败'));
image.src = source;
});
}
async function readMatch3DSpritesheetImageSource(
source: string,
signal?: AbortSignal,
) {
const response = await readAssetBytes(source, {
signal,
expireSeconds: 300,
});
const blob = await response.blob();
const canCreateObjectUrl =
typeof URL.createObjectURL === 'function' &&
typeof URL.revokeObjectURL === 'function';
if (canCreateObjectUrl) {
return {
imageSource: URL.createObjectURL(blob),
revoke: (imageSource: string) => URL.revokeObjectURL(imageSource),
};
}
return {
imageSource: await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败'));
reader.readAsDataURL(blob);
}),
revoke: () => {},
};
}
async function decodeMatch3DSpritesheetImage(
source: string,
signal?: AbortSignal,
) {
const { imageSource, revoke } = await readMatch3DSpritesheetImageSource(
source,
signal,
);
try {
const image = await loadMatch3DSpritesheetImage(imageSource);
if (signal?.aborted) {
throw new DOMException('spritesheet 读取已取消', 'AbortError');
}
const width = Math.max(1, image.naturalWidth || image.width || 1);
const height = Math.max(1, image.naturalHeight || image.height || 1);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', {
willReadFrequently: true,
});
if (!context) {
throw new Error('浏览器不支持解析抓大鹅 spritesheet');
}
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
const alpha = new Uint8ClampedArray(width * height);
for (let index = 0; index < alpha.length; index += 1) {
alpha[index] = pixels[index * 4 + 3] ?? 0;
}
return {
alpha,
height,
image,
width,
};
} finally {
revoke(imageSource);
}
}
function cropMatch3DSpritesheetRegionToDataUrl(
image: HTMLImageElement,
region: Match3DSpritesheetRegion,
) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(region.width));
canvas.height = Math.max(1, Math.round(region.height));
const context = canvas.getContext('2d');
if (!context) {
throw new Error('浏览器不支持裁切抓大鹅 spritesheet');
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(
image,
region.x,
region.y,
region.width,
region.height,
0,
0,
canvas.width,
canvas.height,
);
return canvas.toDataURL('image/png');
}
function floodFillMatch3DSpritesheetComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
}: {
alpha: ArrayLike<number>;
visited: Uint8Array;
width: number;
height: number;
start: number;
alphaThreshold: number;
}): Match3DDetectedComponent {
const stack = [start];
visited[start] = 1;
let minX = start % width;
let maxX = minX;
let minY = Math.floor(start / width);
let maxY = minY;
let area = 0;
while (stack.length > 0) {
const index = stack.pop()!;
const x = index % width;
const y = Math.floor(index / width);
area += 1;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
visitMatch3DSpritesheetNeighbor(
index - 1,
x > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitMatch3DSpritesheetNeighbor(
index + 1,
x + 1 < width,
alpha,
visited,
stack,
alphaThreshold,
);
visitMatch3DSpritesheetNeighbor(
index - width,
y > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitMatch3DSpritesheetNeighbor(
index + width,
y + 1 < height,
alpha,
visited,
stack,
alphaThreshold,
);
}
return {
area,
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
};
}
function visitMatch3DSpritesheetNeighbor(
index: number,
inBounds: boolean,
alpha: ArrayLike<number>,
visited: Uint8Array,
stack: number[],
alphaThreshold: number,
) {
if (!inBounds || visited[index] || (alpha[index] ?? 0) <= alphaThreshold) {
return;
}
visited[index] = 1;
stack.push(index);
}