303 lines
9.2 KiB
TypeScript
303 lines
9.2 KiB
TypeScript
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] = Array.isArray(body[key]) ? imageUrls : 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 (IMAGE_SINGLE_INPUT_KEYS.some((key) => Array.isArray(body[key]))) 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);
|
||
}
|