study / web /src /lib /image-size-preview.ts
xiaolongmr
整合功能更新并排除本地恢复产物
2dc6fe4
Raw History Blame Contribute Delete
9.8 kB
export type ImageResolutionTier = "1k" | "2k" | "4k";
export type ImageSizePreviewSource = "account_pool" | "image_api";
export type ImageSizePreview = {
width: number;
height: number;
value: string;
label: string;
};
const MAX_4K_EDGE = 3840;
const MAX_4K_PIXELS = 8_294_400;
const MAX_2K_EDGE = 2048;
const MAX_STANDARD_EDGE = 1024;
const ACCOUNT_POOL_TARGET_PIXELS = 1536 * 1024;
const SIZE_STEP = 8;
const FOUR_K_SIZE_STEP = 16;
const FOUR_K_MIN_EDGE = 16;
const FOUR_K_MIN_PIXELS = 655_360;
export const MAX_CUSTOM_ASPECT_RATIO = 3;
function floorToStep(value: number, step: number) {
return Math.max(step, Math.floor(value / step) * step);
}
function parseExplicitSize(value: string): [number, number] | null {
const match = String(value || "").trim().match(/^(\d{2,5})\s*x\s*(\d{2,5})$/i);
if (!match) {
return null;
}
return [Number(match[1]), Number(match[2])];
}
export function normalizeAspectRatioInput(value: string) {
return String(value || "")
.trim()
.replace(/[:]/g, ":")
.replace(/[/]/g, "/")
.replace(/[×✕]/g, "x")
.replace(/\s+/g, "")
.replace(/[xX/]/g, ":");
}
export function parseAspectRatio(value: string): [number, number] | null {
const match = normalizeAspectRatioInput(value).match(/^([1-9]\d{0,4}):([1-9]\d{0,4})$/);
if (!match) {
return null;
}
return [Number(match[1]), Number(match[2])];
}
export function isSupportedAspectRatio(value: string) {
const ratio = parseAspectRatio(value);
if (!ratio) {
return false;
}
const longSide = Math.max(...ratio);
const shortSide = Math.min(...ratio);
return longSide / shortSide <= MAX_CUSTOM_ASPECT_RATIO;
}
function fitDimensionsToEdge(width: number, height: number, maxEdge: number, forceStep = false) {
const safeWidth = Math.max(1, Math.round(width || 1));
const safeHeight = Math.max(1, Math.round(height || 1));
const scale = Math.min(1, maxEdge / Math.max(safeWidth, safeHeight));
if (scale < 1 || forceStep) {
return {
width: floorToStep(safeWidth * scale, SIZE_STEP),
height: floorToStep(safeHeight * scale, SIZE_STEP),
};
}
return { width: safeWidth, height: safeHeight };
}
function fitRatioToEdge(widthRatio: number, heightRatio: number, maxEdge: number) {
const scale = maxEdge / Math.max(widthRatio, heightRatio);
return fitDimensionsToEdge(
Math.round(widthRatio * scale),
Math.round(heightRatio * scale),
maxEdge,
true,
);
}
function fitRatioToPixelArea(widthRatio: number, heightRatio: number, targetPixels: number) {
const scale = Math.sqrt(targetPixels / (widthRatio * heightRatio));
return {
width: Math.max(1, Math.round(widthRatio * scale)),
height: Math.max(1, Math.round(heightRatio * scale)),
};
}
function accountPoolPresetDimensions(widthRatio: number, heightRatio: number) {
if (widthRatio === heightRatio) {
return { width: 1254, height: 1254 };
}
if (widthRatio === heightRatio * 3) {
return { width: 2138, height: 736, label: "约 1881-2138 × 736-836" };
}
if (widthRatio * 3 === heightRatio) {
return { width: 736, height: 2135 };
}
if (widthRatio === heightRatio * 2) {
return { width: 1774, height: 887 };
}
if (widthRatio * 2 === heightRatio) {
return { width: 887, height: 1774 };
}
if (widthRatio * 2 === heightRatio * 3) {
return { width: 1536, height: 1024 };
}
if (widthRatio * 3 === heightRatio * 2) {
return { width: 1024, height: 1536 };
}
if (widthRatio * 4 === heightRatio * 5) {
return { width: 1402, height: 1122 };
}
if (widthRatio * 5 === heightRatio * 4) {
return { width: 1122, height: 1402 };
}
if (widthRatio * 9 === heightRatio * 16) {
return { width: 1672, height: 941 };
}
if (widthRatio * 3 === heightRatio * 4) {
return { width: 1448, height: 1086 };
}
if (widthRatio * 4 === heightRatio * 3) {
return { width: 1086, height: 1448 };
}
if (widthRatio * 16 === heightRatio * 9) {
return { width: 941, height: 1672 };
}
return null;
}
function accountPoolDimensions(widthRatio: number, heightRatio: number): ImageSizePreview {
const preset = accountPoolPresetDimensions(widthRatio, heightRatio);
const dimensions = preset || fitRatioToPixelArea(widthRatio, heightRatio, ACCOUNT_POOL_TARGET_PIXELS);
let label = preset?.label || `${dimensions.width} × ${dimensions.height}`;
// Arbitrary ratios are rounded by the upstream image tool and can differ by one
// pixel on the long edge. The range reflects repeated real account-pool runs.
if (!preset && dimensions.width > dimensions.height) {
label = `${dimensions.width - 1}-${dimensions.width} × ${dimensions.height}`;
} else if (!preset && dimensions.height > dimensions.width) {
label = `${dimensions.width} × ${dimensions.height - 1}-${dimensions.height}`;
}
return {
...dimensions,
value: `${dimensions.width}x${dimensions.height}`,
label,
};
}
function isValid4kDimensions(width: number, height: number) {
if (
width < FOUR_K_MIN_EDGE ||
height < FOUR_K_MIN_EDGE ||
width > MAX_4K_EDGE ||
height > MAX_4K_EDGE ||
width % FOUR_K_SIZE_STEP !== 0 ||
height % FOUR_K_SIZE_STEP !== 0
) {
return false;
}
const pixels = width * height;
if (pixels < FOUR_K_MIN_PIXELS || pixels > MAX_4K_PIXELS) {
return false;
}
return Math.max(width, height) / Math.max(1, Math.min(width, height)) <= MAX_CUSTOM_ASPECT_RATIO;
}
function fit4kDimensions(width: number, height: number) {
let nextWidth = Math.max(1, Math.round(width || 1));
let nextHeight = Math.max(1, Math.round(height || 1));
const scale = Math.min(
1,
MAX_4K_EDGE / Math.max(nextWidth, nextHeight),
Math.sqrt(MAX_4K_PIXELS / (nextWidth * nextHeight)),
);
if (scale < 1) {
nextWidth = floorToStep(nextWidth * scale, FOUR_K_SIZE_STEP);
nextHeight = floorToStep(nextHeight * scale, FOUR_K_SIZE_STEP);
}
while (Math.max(nextWidth, nextHeight) > MAX_4K_EDGE || nextWidth * nextHeight > MAX_4K_PIXELS) {
if (nextWidth >= nextHeight && nextWidth > FOUR_K_SIZE_STEP) {
nextWidth -= FOUR_K_SIZE_STEP;
} else if (nextHeight > FOUR_K_SIZE_STEP) {
nextHeight -= FOUR_K_SIZE_STEP;
} else {
break;
}
}
return { width: nextWidth, height: nextHeight };
}
function fit4kRatio(widthRatio: number, heightRatio: number) {
const ratio = widthRatio / heightRatio;
const landscape = widthRatio >= heightRatio;
const theoreticalLongEdge = landscape
? Math.sqrt(MAX_4K_PIXELS * ratio)
: Math.sqrt(MAX_4K_PIXELS / ratio);
let longEdge = floorToStep(Math.min(MAX_4K_EDGE, theoreticalLongEdge), FOUR_K_SIZE_STEP);
while (longEdge >= FOUR_K_MIN_EDGE) {
const width = landscape
? longEdge
: floorToStep((longEdge * widthRatio) / heightRatio, FOUR_K_SIZE_STEP);
const height = landscape
? floorToStep((longEdge * heightRatio) / widthRatio, FOUR_K_SIZE_STEP)
: longEdge;
if (isValid4kDimensions(width, height)) {
return { width, height };
}
longEdge -= FOUR_K_SIZE_STEP;
}
return null;
}
function closestSupportedSize(
canonical: { width: number; height: number },
supportedSizes: string[],
resolution: ImageResolutionTier,
) {
const seen = new Set<string>();
const candidates = supportedSizes.flatMap((value) => {
const parsed = parseExplicitSize(value);
if (!parsed) {
return [];
}
const dimensions = resolution === "4k" ? fit4kDimensions(parsed[0], parsed[1]) : { width: parsed[0], height: parsed[1] };
const key = `${dimensions.width}x${dimensions.height}`;
if (seen.has(key)) {
return [];
}
seen.add(key);
return [dimensions];
});
if (candidates.length === 0) {
return canonical;
}
const exact = candidates.find((item) => item.width === canonical.width && item.height === canonical.height);
if (exact) {
return exact;
}
const targetRatio = canonical.width / canonical.height;
return [...candidates].sort((left, right) => {
const leftDelta = Math.abs(left.width / left.height - targetRatio);
const rightDelta = Math.abs(right.width / right.height - targetRatio);
if (leftDelta !== rightDelta) {
return leftDelta - rightDelta;
}
return right.width * right.height - left.width * left.height;
})[0];
}
export function getImageSizePreview(
value: string,
resolution: string,
supportedSizes: string[] = [],
source: ImageSizePreviewSource = "image_api",
): ImageSizePreview | null {
const tier: ImageResolutionTier = resolution === "4k" ? "4k" : resolution === "2k" ? "2k" : "1k";
const explicit = parseExplicitSize(value);
let canonical: { width: number; height: number } | null = null;
if (explicit) {
if (Math.max(...explicit) / Math.min(...explicit) > MAX_CUSTOM_ASPECT_RATIO) {
return null;
}
if (source === "account_pool") {
return accountPoolDimensions(explicit[0], explicit[1]);
}
canonical = tier === "4k"
? fit4kDimensions(explicit[0], explicit[1])
: fitDimensionsToEdge(explicit[0], explicit[1], tier === "2k" ? MAX_2K_EDGE : MAX_STANDARD_EDGE);
} else {
const ratio = parseAspectRatio(value);
if (!ratio || !isSupportedAspectRatio(value)) {
return null;
}
if (source === "account_pool") {
return accountPoolDimensions(ratio[0], ratio[1]);
}
canonical = tier === "4k"
? fit4kRatio(ratio[0], ratio[1])
: fitRatioToEdge(ratio[0], ratio[1], tier === "2k" ? MAX_2K_EDGE : MAX_STANDARD_EDGE);
}
if (!canonical) {
return null;
}
const dimensions = closestSupportedSize(canonical, supportedSizes, tier);
return {
...dimensions,
value: `${dimensions.width}x${dimensions.height}`,
label: `${dimensions.width} × ${dimensions.height}`,
};
}