ai-chatbot/lib/ai/vip98k-task-invocation.ts

302 lines
9.1 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.

export type Vip98kTaskInvocation = {
kind: string;
method: string;
path: string;
contentType: string;
requestExample: Record<string, unknown>;
inputSchema?: Record<string, unknown> | null;
resultMethod: string;
resultPath: string;
};
type KnownImageInput = "images" | "video_clips";
const VIP98K_IMAGE_SIZE_LONGEST_EDGE: Record<string, number> = {
"1K": 1024,
"2K": 2048,
"4K": 4096,
};
function normalizeVip98kPixelSize(value: unknown) {
const match = String(value || "")
.trim()
.match(/^(\d+)\s*[x*×]\s*(\d+)$/i);
if (!match?.[1] || !match[2]) return null;
const sourceWidth = Number(match[1]);
const sourceHeight = Number(match[2]);
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
sourceWidth < 1 ||
sourceHeight < 1
) {
return null;
}
const scale = Math.min(1, 4096 / Math.max(sourceWidth, sourceHeight));
return `${Math.max(1, Math.round(sourceWidth * scale))}x${Math.max(
1,
Math.round(sourceHeight * scale),
)}`;
}
export function resolveVip98kImageTaskSize(params: {
imageSize?: unknown;
aspectRatio?: unknown;
fallbackSize?: unknown;
}) {
const explicitSize = normalizeVip98kPixelSize(params.imageSize);
if (explicitSize) return explicitSize;
const ratioMatch = String(params.aspectRatio || "")
.trim()
.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/);
const sizeBucket = String(params.imageSize || "").trim().toUpperCase();
const longestEdge = VIP98K_IMAGE_SIZE_LONGEST_EDGE[sizeBucket];
if (ratioMatch?.[1] && ratioMatch[2] && longestEdge) {
const ratioWidth = Number(ratioMatch[1]);
const ratioHeight = Number(ratioMatch[2]);
if (ratioWidth > 0 && ratioHeight > 0) {
const scale = longestEdge / Math.max(ratioWidth, ratioHeight);
const roundToEight = (value: number) =>
Math.max(8, Math.min(4096, Math.round(value / 8) * 8));
return `${roundToEight(ratioWidth * scale)}x${roundToEight(
ratioHeight * scale,
)}`;
}
}
const fallbackPixelSize = normalizeVip98kPixelSize(params.fallbackSize);
if (fallbackPixelSize) return fallbackPixelSize;
return String(params.fallbackSize || "").trim().toLowerCase() === "auto"
? "auto"
: undefined;
}
const IMAGE_ARRAY_INPUT_KEYS = [
"file_urls",
"images",
"imageUrls",
"referenceImages",
"reference_images",
] as const;
const IMAGE_SINGLE_INPUT_KEYS = [
"image",
"imageUrl",
"image_url",
"firstFrameImage",
"first_frame_image",
"firstFrameImageUrl",
"first_frame_image_url",
] as const;
const KNOWN_IMAGE_TOOL_INPUTS: Record<string, KnownImageInput> = {
"google/nano-banana-2/reference-to-image": "video_clips",
"google/nano-banana-pro/edit": "images",
"openai/gpt-image-2/edit": "images",
};
const KNOWN_IMAGE_PROCESSING_TOOL_REQUESTS: Record<
string,
Record<string, unknown>
> = {
"remove-background": { image_url: "" },
enhance: { image_url: "", scale_factor: 4 },
};
function getInvocationToolId(invocation: Vip98kTaskInvocation | null) {
if (!invocation || invocation.kind !== "tool") return null;
const query = invocation.path.split("?", 2)[1] || "";
return new URLSearchParams(query).get("tool_id");
}
function invocationTargetsModel(
invocation: Vip98kTaskInvocation | null,
modelId: string,
) {
if (!invocation) return false;
const expected = modelId.trim().toLowerCase();
if (!expected) return false;
const requestModel = String(invocation.requestExample.model || "")
.trim()
.toLowerCase();
if (requestModel) return requestModel === expected;
return getInvocationToolId(invocation)?.toLowerCase() === expected;
}
export function getVip98kImageBindingKeys(invocation: Vip98kTaskInvocation | null) {
if (!invocation) return [];
const body = invocation.requestExample || {};
return [
...IMAGE_ARRAY_INPUT_KEYS.filter((key) => key in body),
...IMAGE_SINGLE_INPUT_KEYS.filter((key) => key in body),
...(Array.isArray(body.video_clips) ? ["video_clips" as const] : []),
];
}
export function invocationSupportsVip98kImages(
invocation: Vip98kTaskInvocation | null,
) {
return getVip98kImageBindingKeys(invocation).length > 0;
}
export function bindVip98kImages(
body: Record<string, unknown>,
imageUrls: string[],
) {
const bindingKeys: string[] = [];
if (imageUrls.length === 0) return bindingKeys;
for (const key of IMAGE_ARRAY_INPUT_KEYS) {
if (!(key in body)) continue;
body[key] = imageUrls;
bindingKeys.push(key);
}
for (const key of IMAGE_SINGLE_INPUT_KEYS) {
if (!(key in body)) continue;
body[key] = imageUrls[0];
bindingKeys.push(key);
}
if (Array.isArray(body.video_clips)) {
const first = body.video_clips[0];
const template =
first && typeof first === "object" && !Array.isArray(first)
? (first as Record<string, unknown>)
: {};
body.video_clips = imageUrls.map((url) => ({ ...template, url }));
bindingKeys.push("video_clips");
}
const secondImageBindings = [
"lastFrameImage",
"last_frame_image",
"endImageUrl",
"end_image_url",
] as const;
if (imageUrls[1]) {
for (const key of secondImageBindings) {
if (!(key in body)) continue;
body[key] = imageUrls[1];
bindingKeys.push(key);
}
}
return bindingKeys;
}
function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) {
const body = invocation.requestExample || {};
let score = 0;
if ("file_urls" in body) score += 60;
if (IMAGE_ARRAY_INPUT_KEYS.some((key) => key in body)) score += 40;
if (Array.isArray(body.video_clips)) score += 35;
if (IMAGE_SINGLE_INPUT_KEYS.some((key) => key in body)) score += 15;
if (invocation.kind === "task") score += 10;
if (invocation.path === "/extensions/v1/tasks") score += 10;
return score;
}
export function selectVip98kTaskInvocation(params: {
modelId: string;
configuredInvocation: Vip98kTaskInvocation | null;
configuredInvocations?: Vip98kTaskInvocation[];
imageCount?: number;
}) {
const candidates = [
...(params.configuredInvocations || []),
...(params.configuredInvocation ? [params.configuredInvocation] : []),
].filter(
(candidate, index, items) =>
candidate.path.startsWith("/") &&
items.findIndex(
(item) =>
item.method === candidate.method &&
item.path === candidate.path &&
JSON.stringify(item.requestExample) === JSON.stringify(candidate.requestExample) &&
JSON.stringify(item.inputSchema || null) ===
JSON.stringify(candidate.inputSchema || null),
) === index,
);
if ((params.imageCount || 0) > 0) {
const referenceInvocation = candidates
.filter(invocationSupportsVip98kImages)
.sort((a, b) => scoreReferenceInvocation(b) - scoreReferenceInvocation(a))[0];
if (referenceInvocation) {
return resolveVip98kTaskInvocation(params.modelId, referenceInvocation);
}
}
return resolveVip98kTaskInvocation(
params.modelId,
params.configuredInvocation || candidates[0] || null,
);
}
function createKnownImageToolInvocation(
modelId: string,
imageInput: KnownImageInput
): Vip98kTaskInvocation {
const encodedModelId = encodeURIComponent(modelId);
const requestExample =
imageInput === "video_clips"
? {
prompt: "",
video_clips: [{ fps: 1, url: "", ends: 0, start: 0 }],
}
: { prompt: "", images: [""] };
return {
kind: "tool",
method: "POST",
path: `/api/external/tools/generate?tool_id=${encodedModelId}`,
contentType: "application/json",
requestExample,
resultMethod: "GET",
resultPath: `/api/external/tools/result?tool_id=${encodedModelId}&taskId={task_id}`,
};
}
function createKnownProcessingToolInvocation(
modelId: string,
requestExample: Record<string, unknown>
): Vip98kTaskInvocation {
const encodedModelId = encodeURIComponent(modelId);
return {
kind: "tool",
method: "POST",
path: `/api/external/tools/generate?tool_id=${encodedModelId}`,
contentType: "application/json",
requestExample: { ...requestExample },
resultMethod: "GET",
resultPath: `/api/external/tools/result?tool_id=${encodedModelId}&taskId={task_id}`,
};
}
/**
* A frontend alias may resolve to a different VIP98K tool at runtime. In that
* case the selected model's invocation cannot be reused because its endpoint
* and reference-image field belong to the alias rather than the resolved tool.
*/
export function resolveVip98kTaskInvocation(
modelId: string,
configuredInvocation: Vip98kTaskInvocation | null
) {
const normalizedModelId = modelId.trim().toLowerCase();
const processingRequest = KNOWN_IMAGE_PROCESSING_TOOL_REQUESTS[normalizedModelId];
if (processingRequest) {
if (invocationTargetsModel(configuredInvocation, normalizedModelId)) {
return configuredInvocation;
}
return createKnownProcessingToolInvocation(modelId, processingRequest);
}
const knownImageInput = KNOWN_IMAGE_TOOL_INPUTS[normalizedModelId];
if (!knownImageInput) return configuredInvocation;
if (invocationTargetsModel(configuredInvocation, normalizedModelId)) {
return configuredInvocation;
}
return createKnownImageToolInvocation(modelId, knownImageInput);
}