332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import dotenv from "dotenv";
|
|
import sharp from "sharp";
|
|
|
|
dotenv.config({ path: ".env.local" });
|
|
dotenv.config();
|
|
|
|
const BASE_URL = String(process.env.VIP98K_BASE_URL || "https://api.vip98k.com")
|
|
.trim()
|
|
.replace(/\/+$/, "")
|
|
.replace(/\/v1$/i, "");
|
|
const DEFAULT_REFERENCE_URLS = [
|
|
"https://dummyimage.com/512x512/2563eb/ffffff.png&text=BLUE+A",
|
|
"https://dummyimage.com/512x512/dc2626/ffffff.png&text=RED+B",
|
|
];
|
|
const REFERENCE_URLS = (() => {
|
|
const configured = String(process.env.VIP98K_IMAGE_TEST_REFERENCES || "").trim();
|
|
if (!configured) return DEFAULT_REFERENCE_URLS;
|
|
const urls = configured
|
|
.split(/\r?\n/)
|
|
.map((url) => url.trim())
|
|
.filter((url) => /^https?:\/\//i.test(url));
|
|
if (urls.length < 2) {
|
|
throw new Error("VIP98K_IMAGE_TEST_REFERENCES must contain at least two URLs");
|
|
}
|
|
return urls;
|
|
})();
|
|
const USES_CUSTOM_REFERENCES = Boolean(
|
|
String(process.env.VIP98K_IMAGE_TEST_REFERENCES || "").trim(),
|
|
);
|
|
const MODEL_FILTER = new Set(
|
|
String(process.env.VIP98K_IMAGE_TEST_MODELS || "")
|
|
.split(",")
|
|
.map((modelId) => modelId.trim())
|
|
.filter(Boolean),
|
|
);
|
|
const TEST_PROMPT =
|
|
String(process.env.VIP98K_IMAGE_TEST_PROMPT || "").trim() ||
|
|
"Use BOTH references. Place the blue A card on the left and the red B card on the right. " +
|
|
"Preserve both labels and colors in a landscape 16:9 composition at 1024x576.";
|
|
const TERMINAL_STATUSES = new Set(["succeeded", "success", "completed", "done"]);
|
|
const FAILURE_STATUSES = new Set(["failed", "error", "timeout", "canceled", "cancelled"]);
|
|
|
|
type JsonRecord = Record<string, any>;
|
|
type Invocation = {
|
|
method?: string;
|
|
path?: string;
|
|
content_type?: string;
|
|
request_example?: JsonRecord;
|
|
result_path?: string;
|
|
};
|
|
|
|
function asRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as JsonRecord)
|
|
: {};
|
|
}
|
|
|
|
async function readJson(response: Response) {
|
|
const text = await response.text();
|
|
let payload: unknown = text;
|
|
try {
|
|
payload = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
// Keep the raw provider body for diagnostics.
|
|
}
|
|
if (!response.ok) {
|
|
const record = asRecord(payload);
|
|
const message = record.error?.message || record.error || record.message || text;
|
|
throw new Error(`${String(message || "VIP98K request failed")} (HTTP ${response.status})`);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function requestKeys(invocation: Invocation) {
|
|
return Object.keys(asRecord(invocation.request_example)).map((key) => key.toLowerCase());
|
|
}
|
|
|
|
function supportsMultipleImages(invocation: Invocation) {
|
|
return requestKeys(invocation).some((key) =>
|
|
["file_urls", "images", "image_urls", "reference_images"].includes(key),
|
|
);
|
|
}
|
|
|
|
function extractStatus(payload: unknown) {
|
|
const root = asRecord(payload);
|
|
const data = asRecord(root.data || root);
|
|
return String(data.status || data.state || root.status || "").toLowerCase();
|
|
}
|
|
|
|
function extractTaskId(payload: unknown) {
|
|
const root = asRecord(payload);
|
|
const data = asRecord(root.data || root);
|
|
return String(data.taskId || data.task_id || data.id || root.id || "");
|
|
}
|
|
|
|
async function fetchCatalog(apiKey: string) {
|
|
const [catalogPayload, authorizedPayload] = await Promise.all([
|
|
readJson(
|
|
await fetch(`${BASE_URL}/api/v2/catalog/models`, {
|
|
signal: AbortSignal.timeout(60_000),
|
|
}),
|
|
),
|
|
readJson(
|
|
await fetch(`${BASE_URL}/v1/models`, {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
signal: AbortSignal.timeout(60_000),
|
|
}),
|
|
),
|
|
]);
|
|
const catalog = asRecord(catalogPayload);
|
|
const authorized = asRecord(authorizedPayload);
|
|
const authorizedIds = new Set(
|
|
(Array.isArray(authorized.data) ? authorized.data : []).map((model) =>
|
|
String(asRecord(model).id || ""),
|
|
),
|
|
);
|
|
const sections = Array.isArray(asRecord(catalog.data).sections)
|
|
? asRecord(catalog.data).sections
|
|
: [];
|
|
return sections
|
|
.flatMap((section: unknown) => {
|
|
const record = asRecord(section);
|
|
return Array.isArray(record.models) ? record.models : [];
|
|
})
|
|
.map(asRecord)
|
|
.filter(
|
|
(model: JsonRecord) => {
|
|
const capabilities = (Array.isArray(model.capabilities) ? model.capabilities : []).map(
|
|
(capability: unknown) => String(capability).toLowerCase(),
|
|
);
|
|
return (
|
|
authorizedIds.has(String(model.id || "")) &&
|
|
String(model.type || model.category || "").toLowerCase() === "image" &&
|
|
capabilities.some((capability: string) =>
|
|
["text_to_image", "text-to-image", "openai.images.generations"].includes(
|
|
capability,
|
|
),
|
|
)
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
async function resolveOutputUrl(apiKey: string, taskId: string) {
|
|
const payload = await readJson(
|
|
await fetch(
|
|
`${BASE_URL}/api/external/task/result?taskId=${encodeURIComponent(taskId)}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
signal: AbortSignal.timeout(60_000),
|
|
},
|
|
),
|
|
);
|
|
const data = asRecord(asRecord(payload).data);
|
|
const outputs = asRecord(data.outputs);
|
|
const generatedImages = Array.isArray(outputs.generated_image)
|
|
? outputs.generated_image
|
|
: [];
|
|
const first = asRecord(generatedImages[0]);
|
|
const url = String(first.image_url || first.file_download_url || "");
|
|
if (!url.startsWith("http")) throw new Error("VIP98K delivery result has no image URL");
|
|
return url;
|
|
}
|
|
|
|
async function pollTask(apiKey: string, taskId: string, resultPath: string) {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < 15 * 60_000) {
|
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 5000));
|
|
const payload = await readJson(
|
|
await fetch(`${BASE_URL}${resultPath}`, {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
signal: AbortSignal.timeout(60_000),
|
|
}),
|
|
);
|
|
const status = extractStatus(payload);
|
|
if (FAILURE_STATUSES.has(status)) throw new Error(`VIP98K task failed: ${status}`);
|
|
if (TERMINAL_STATUSES.has(status)) {
|
|
return {
|
|
durationMs: Date.now() - startedAt,
|
|
outputUrl: await resolveOutputUrl(apiKey, taskId),
|
|
};
|
|
}
|
|
}
|
|
throw new Error("VIP98K task did not finish within 15 minutes");
|
|
}
|
|
|
|
async function analyzeImage(url: string, modelId: string) {
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
if (!response.ok) throw new Error(`Generated image download failed (HTTP ${response.status})`);
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
const image = sharp(buffer).removeAlpha();
|
|
const metadata = await image.metadata();
|
|
const outputPath = resolve(
|
|
".local",
|
|
`vip98k-${modelId.replace(/[^a-z0-9._-]+/gi, "-")}-latest.${
|
|
metadata.format === "jpeg" ? "jpg" : metadata.format || "png"
|
|
}`,
|
|
);
|
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
await writeFile(outputPath, buffer);
|
|
const { data, info } = await image.resize(256, 256, { fit: "fill" }).raw().toBuffer({
|
|
resolveWithObject: true,
|
|
});
|
|
let bluePixels = 0;
|
|
let redPixels = 0;
|
|
for (let offset = 0; offset < data.length; offset += info.channels) {
|
|
const red = data[offset] || 0;
|
|
const green = data[offset + 1] || 0;
|
|
const blue = data[offset + 2] || 0;
|
|
if (blue > 100 && blue > red * 1.25 && blue > green * 1.1) bluePixels += 1;
|
|
if (red > 100 && red > blue * 1.25 && red > green * 1.15) redPixels += 1;
|
|
}
|
|
const pixels = info.width * info.height;
|
|
return {
|
|
width: metadata.width || 0,
|
|
height: metadata.height || 0,
|
|
format: metadata.format || "unknown",
|
|
blueShare: Number((bluePixels / pixels).toFixed(4)),
|
|
redShare: Number((redPixels / pixels).toFixed(4)),
|
|
bothReferencesDetected: bluePixels / pixels > 0.03 && redPixels / pixels > 0.03,
|
|
outputPath,
|
|
sha256: createHash("sha256").update(buffer).digest("hex"),
|
|
};
|
|
}
|
|
|
|
async function testModel(apiKey: string, model: JsonRecord) {
|
|
const modelId = String(model.id || "");
|
|
const invocations = (Array.isArray(model.invocations)
|
|
? model.invocations
|
|
: [model.invocation]
|
|
).map(asRecord) as Invocation[];
|
|
const invocation = invocations.find(supportsMultipleImages);
|
|
if (!invocation) {
|
|
return {
|
|
modelId,
|
|
status: "unsupported",
|
|
eligibleForMultiImageEditing: false,
|
|
reason: "The published V2 contract exposes text-to-image only",
|
|
};
|
|
}
|
|
|
|
const keys = requestKeys(invocation);
|
|
const nativeAspectRatio = keys.some((key) => ["ratio", "aspect_ratio"].includes(key));
|
|
const nativeSize = keys.some((key) =>
|
|
["size", "image_size", "resolution", "width", "height"].includes(key),
|
|
);
|
|
const body = { ...asRecord(invocation.request_example) };
|
|
body.model = modelId;
|
|
body.file_urls = REFERENCE_URLS;
|
|
body.prompt = TEST_PROMPT;
|
|
const submitted = await readJson(
|
|
await fetch(`${BASE_URL}${invocation.path}`, {
|
|
method: String(invocation.method || "POST").toUpperCase(),
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
"Content-Type": invocation.content_type || "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(120_000),
|
|
}),
|
|
);
|
|
const taskId = extractTaskId(submitted);
|
|
if (!taskId) throw new Error(`${modelId} returned no task ID`);
|
|
const resultPath = String(invocation.result_path || "").replace(
|
|
"{task_id}",
|
|
encodeURIComponent(taskId),
|
|
);
|
|
if (!resultPath) throw new Error(`${modelId} publishes no result path`);
|
|
const completed = await pollTask(apiKey, taskId, resultPath);
|
|
const image = await analyzeImage(completed.outputUrl, modelId);
|
|
const passed = USES_CUSTOM_REFERENCES || image.bothReferencesDetected;
|
|
return {
|
|
modelId,
|
|
status: passed ? "passed" : "failed",
|
|
eligibleForMultiImageEditing: passed,
|
|
referenceVerification: USES_CUSTOM_REFERENCES ? "manual" : "color-marker",
|
|
publishedRequestKeys: keys.sort(),
|
|
requested: { referenceImageCount: 2, aspectRatio: "16:9", size: "1024x576" },
|
|
nativeParameterSupport: { aspectRatio: nativeAspectRatio, size: nativeSize },
|
|
actual: image,
|
|
durationMs: completed.durationMs,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const apiKey = String(process.env.VIP98K_API_KEY || "").trim();
|
|
if (!apiKey) throw new Error("VIP98K_API_KEY is required");
|
|
const authorizedModels = await fetchCatalog(apiKey);
|
|
const models =
|
|
MODEL_FILTER.size > 0
|
|
? authorizedModels.filter((model: JsonRecord) =>
|
|
MODEL_FILTER.has(String(model.id || "")),
|
|
)
|
|
: authorizedModels;
|
|
if (models.length === 0) throw new Error("No authorized VIP98K image models matched");
|
|
const results: unknown[] = [];
|
|
for (const model of models) {
|
|
try {
|
|
results.push(await testModel(apiKey, model));
|
|
} catch (error) {
|
|
results.push({
|
|
modelId: String(model.id || ""),
|
|
status: "failed",
|
|
eligibleForMultiImageEditing: false,
|
|
error: error instanceof Error ? error.message.slice(0, 1000) : String(error),
|
|
});
|
|
}
|
|
}
|
|
const report = {
|
|
generatedAt: new Date().toISOString(),
|
|
baseUrl: BASE_URL,
|
|
authorizedImageModelCount: authorizedModels.length,
|
|
testedImageModelCount: models.length,
|
|
results,
|
|
};
|
|
const outputPath = resolve(
|
|
process.env.VIP98K_IMAGE_TEST_REPORT || ".local/vip98k-image-model-tests.json",
|
|
);
|
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
console.log(JSON.stringify({ ...report, reportPath: outputPath }, null, 2));
|
|
if (!results.some((result) => asRecord(result).status === "passed")) process.exitCode = 1;
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
});
|