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).
353 lines
8.5 KiB
TypeScript
353 lines
8.5 KiB
TypeScript
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);
|
||
}
|