import { randomInt } from 'node:crypto' import sharp from 'sharp' import { AiModelError } from './ai-model-access' import { createVerificationChallenge, readVerificationChallenge, referenceLayoutMatches, verificationReferenceImage, type VerificationChallenge } from './ai-model-verification-output' export interface MultiReferenceChallenge { version: 1; references: VerificationChallenge[] } /** Distinct, ordered pairs prevent a model from passing by copying reference #1. */ export function createMultiReferenceChallenge(count: number): MultiReferenceChallenge { if (!Number.isInteger(count) || count < 2 || count > 8) throw new AiModelError('多图测试数量须为 2 至 8 张', 400) const references: VerificationChallenge[] = [] while (references.length < count) { const next = createVerificationChallenge() if (!references.some(value => value.topLeft === next.topLeft && value.bottomRight === next.bottomRight)) references.push(next) } // The generation is already random; shuffle the final order independently too. for (let i = references.length - 1; i > 0; i--) { const j = randomInt(i + 1); [references[i], references[j]] = [references[j], references[i]] } return { version: 1, references } } export function readMultiReferenceChallenge(value: unknown): MultiReferenceChallenge { const raw = value as MultiReferenceChallenge | null if (raw?.version !== 1 || !Array.isArray(raw.references) || raw.references.length < 2 || raw.references.length > 8 || raw.references.some(value => !value || typeof value !== 'object')) throw new AiModelError('多图测试检查点无效,不能重建或猜测参考图', 409) const references = raw.references.map(readVerificationChallenge) if (new Set(references.map(value => `${value.topLeft}/${value.bottomRight}`)).size !== references.length) throw new AiModelError('多图测试参考图重复,检查点无效', 409) return { version: 1, references } } export function multiReferenceGrid(count: number) { const columns = count <= 4 ? 2 : 4 return { columns, rows: Math.ceil(count / columns) } } export function multiReferencePrompt(challenge: MultiReferenceChallenge): string { const count = challenge.references.length, { columns, rows } = multiReferenceGrid(count) return `你收到 ${count} 张独立参考图。必须逐张读取,将全部参考图按原始输入顺序排成 ${columns} 列 ${rows} 行的等大方格拼图:从左到右、从上到下,每格仅对应一张参考图。每格内严格保留该参考图两个方块各自的原始颜色、形状和左上/右下相对位置。每个色块直接采样对应参考图的原色,禁止改色或调色。所有格子等宽等高;纯白背景,无外边框、无格间空隙,未用的格子留白。平面重绘,不要文字、序号、标志、阴影、纹理、渐变或光照。不能重复第一张图代替其余参考图。` } export async function multiReferenceImages(challenge: MultiReferenceChallenge) { return Promise.all(challenge.references.map(async reference => ({ buffer: await verificationReferenceImage(reference), mimeType: 'image/png' as const }))) } /** Verify each tile against its own hidden challenge, never the adapter's claims. */ export async function validateMultiReferenceOutput(image: Buffer | undefined, challenge: MultiReferenceChallenge): Promise> { if (!image) throw new AiModelError('多图测试未返回图片', 422, 'verification_output_invalid') // Output padding is a layout choice, not evidence that an input was ignored. // Normalize only the exterior white margin; preserve tile order and all pixels // inside the content so missing, repeated or swapped references still fail. const content = await sharp(image, { limitInputPixels: 40_000_000 }).flatten({ background: '#fff' }).trim({ background: '#fff', threshold: 25 }).png().toBuffer() const metadata = await sharp(content, { limitInputPixels: 40_000_000 }).metadata() const storedMetadata = await sharp(image, { limitInputPixels: 40_000_000 }).metadata() const count = challenge.references.length, intended = multiReferenceGrid(count), square = Math.ceil(Math.sqrt(count)) // Image models may preserve square reference panels instead of stretching them. // Test bounded row-major layouts; every input must match its own distinct tile // in one complete layout. We never match a color from a different input's tile. const layouts = [intended, { columns: square, rows: square }, { columns: intended.columns, rows: intended.columns }] .filter((grid, index, list) => list.findIndex(value => value.columns === grid.columns && value.rows === grid.rows) === index) if (!metadata.width || !metadata.height || metadata.width < intended.columns * 64 || metadata.height < intended.rows * 64) throw new AiModelError('多图测试返回的图片无效或尺寸过小', 422, 'verification_output_invalid') let matches: boolean[] = [], matchedGrid: { columns: number; rows: number } | null = null for (const { columns, rows } of layouts) { const attempt: boolean[] = [] for (let index = 0; index < count; index++) { const col = index % columns, row = Math.floor(index / columns) const left = Math.floor(col * metadata.width / columns), top = Math.floor(row * metadata.height / rows) const right = Math.floor((col + 1) * metadata.width / columns), bottom = Math.floor((row + 1) * metadata.height / rows) const tile = await sharp(content, { limitInputPixels: 40_000_000 }).extract({ left, top, width: right - left, height: bottom - top }).png().toBuffer() attempt.push(await referenceLayoutMatches(tile, challenge.references[index])) } if (!matches.length || attempt.filter(Boolean).length > matches.filter(Boolean).length) matches = attempt if (attempt.every(Boolean)) { matchedGrid = { columns, rows }; matches = attempt; break } } if (!matchedGrid) throw new AiModelError(`多图测试未通过:第 ${matches.flatMap((value, index) => value ? [] : [index + 1]).join('、')} 张参考图的颜色或位置未保留`, 422, 'verification_output_invalid') return { referenceImageCount: challenge.references.length, allReferenceImagesMatched: true, referenceMatches: matches, referenceImageAccepted: true, referenceLayoutMatched: true, imageDecoded: true, referenceGrid: matchedGrid, width: storedMetadata.width, height: storedMetadata.height, bytes: image.length, multiReferenceRuleVersion: 1 } }