You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

586 lines
19 KiB

import type { NextRequest } from "next/server";
import type { GenerationInput, GenerationOutput, ModelCapability } from "@/lib/providers/types";
import {
buildPopiserverForwardHeaders,
fetchPopiModelDetail,
getPopiserverBaseUrl,
type PopiAIModel,
} from "@/app/api/_popiserverModels";
import { ApiError } from "@/app/api/_errors";
import { logGatewayRequest, logGatewayResponse } from "@/app/api/_gatewayLogging";
const POPISERVER_TASK_CREATE_PATH = "/api_client/anime/task/create";
const POPISERVER_TASK_LIST_PATH = "/api_client/anime/task/list";
const POPISERVER_MEDIA_UPLOAD_PATH = "/api_client/media/upload";
const POPISERVER_IMAGE_DYNAMIC_KEYS = [
"image",
"images",
"image_url",
"image_urls",
"first_frame",
"first_frame_url",
"last_frame",
"last_frame_url",
"reference_image",
"reference_image_url",
"reference_image_urls",
];
const POPISERVER_VIDEO_DYNAMIC_KEYS = [
"video",
"videos",
"video_url",
"video_urls",
];
const POPISERVER_AUDIO_DYNAMIC_KEYS = [
"audio",
"audios",
"audio_url",
"audio_urls",
];
const POPISERVER_VOICE_DYNAMIC_KEYS = [
"voiceId",
"voice_id",
];
const POPISERVER_MEDIA_DYNAMIC_KEYS = [
...POPISERVER_IMAGE_DYNAMIC_KEYS,
...POPISERVER_VIDEO_DYNAMIC_KEYS,
...POPISERVER_AUDIO_DYNAMIC_KEYS,
];
type PopiserverResponse<T> = {
status?: string;
message?: string;
data?: T;
};
type PopiTaskCreateData = {
id?: number | string;
taskId?: number | string;
};
type PopiTaskListData = {
list?: PopiTask[];
};
type PopiUploadedMedia = {
url?: string;
path?: string;
originUrl?: string;
originPath?: string;
};
type PopiMediaUploadData =
| PopiUploadedMedia
| PopiUploadedMedia[]
| {
list?: PopiUploadedMedia[];
files?: PopiUploadedMedia[];
urls?: string[];
};
type PopiTaskResult = {
images?: string[] | null;
video?: string | null;
voice?: string | null;
cover?: string | null;
};
type PopiTask = {
id?: number | string;
type?: number;
subType?: number;
status?: number;
errorMsg?: string;
userErrorTipMsg?: string;
images?: string[] | null;
voice?: string | null;
resultList?: PopiTaskResult[] | null;
};
export type PopiTaskPollResult =
| { status: "processing" }
| { status: "failed"; error: string }
| { status: "completed"; output: GenerationOutput };
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function firstString(values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.length > 0) return value;
if (Array.isArray(value)) {
const found = firstString(value);
if (found) return found;
}
}
return null;
}
function collectStrings(values: unknown[]): string[] {
const items: string[] = [];
for (const value of values) {
if (typeof value === "string" && value.length > 0) {
items.push(value);
continue;
}
if (Array.isArray(value)) {
items.push(...collectStrings(value));
}
}
return items;
}
function stringArray(value: string | string[] | undefined): string[] {
if (typeof value === "string") return value.length > 0 ? [value] : [];
if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.length > 0);
return [];
}
function firstDynamicString(
dynamicInputs: Record<string, string | string[]> | undefined,
keys: string[]
): string | null {
if (!dynamicInputs) return null;
for (const key of keys) {
const value = dynamicInputs[key];
const found = firstString(Array.isArray(value) ? value : [value]);
if (found) return found;
}
return null;
}
function collectDynamicMedia(
dynamicInputs: Record<string, string | string[]> | undefined,
keys: string[]
): string[] {
if (!dynamicInputs) return [];
const items: string[] = [];
for (const key of keys) {
items.push(...stringArray(dynamicInputs[key]));
}
return items;
}
function mediaResourceUrl(media: PopiUploadedMedia): string | null {
return media.url || media.originUrl || media.path || media.originPath || null;
}
function isUploadedMedia(value: PopiMediaUploadData): value is PopiUploadedMedia {
return (
"url" in value ||
"originUrl" in value ||
"path" in value ||
"originPath" in value
);
}
function isUploadableMedia(value: string): boolean {
return value.startsWith("data:");
}
function parseDataUrl(value: string): { mimeType: string; base64: string } | null {
const match = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/);
if (!match) return null;
return {
mimeType: match[1] || "application/octet-stream",
base64: match[2],
};
}
function extensionForMimeType(mimeType: string): string {
const normalized = mimeType.toLowerCase();
if (normalized === "image/jpeg") return "jpg";
if (normalized === "image/png") return "png";
if (normalized === "image/webp") return "webp";
if (normalized === "image/gif") return "gif";
if (normalized === "video/mp4") return "mp4";
if (normalized === "video/webm") return "webm";
if (normalized === "audio/mpeg" || normalized === "audio/mp3") return "mp3";
if (normalized === "audio/wav" || normalized === "audio/x-wav") return "wav";
return normalized.split("/")[1]?.replace(/[^a-z0-9]/g, "") || "bin";
}
function dataUrlToFile(value: string, index: number): File {
const dataUrl = parseDataUrl(value);
if (!dataUrl) {
throw ApiError.badRequest("Only base64 data URLs can be uploaded to PopiServer media.");
}
const binary = Buffer.from(dataUrl.base64, "base64");
const extension = extensionForMimeType(dataUrl.mimeType);
return new File([binary], `canvas-input-${Date.now()}-${index}.${extension}`, {
type: dataUrl.mimeType,
});
}
function uploadedMediaUrls(data: PopiMediaUploadData | undefined): string[] {
if (!data) return [];
if (Array.isArray(data)) {
return data.map(mediaResourceUrl).filter((url): url is string => Boolean(url));
}
if ("urls" in data && Array.isArray(data.urls)) {
return data.urls.filter((url): url is string => typeof url === "string" && url.length > 0);
}
const list = ("list" in data && Array.isArray(data.list))
? data.list
: ("files" in data && Array.isArray(data.files))
? data.files
: null;
if (list) {
return list.map(mediaResourceUrl).filter((url): url is string => Boolean(url));
}
const single = isUploadedMedia(data) ? mediaResourceUrl(data) : null;
return single ? [single] : [];
}
async function uploadPopiMediaBatch(
request: NextRequest,
token: string,
media: string[]
): Promise<string[]> {
const uploadablePositions = media
.map((item, index) => ({ item, index }))
.filter(({ item }) => isUploadableMedia(item));
const uniqueUploadable = [...new Set(uploadablePositions.map(({ item }) => item))];
if (uniqueUploadable.length === 0) return media;
const formData = new FormData();
formData.append("multiple", "true");
uniqueUploadable.forEach((item, index) => {
formData.append("file", dataUrlToFile(item, index));
});
const url = new URL(getPopiserverBaseUrl(request) + POPISERVER_MEDIA_UPLOAD_PATH);
url.searchParams.set("multiple", "true");
const init: RequestInit = {
method: "POST",
headers: buildPopiserverForwardHeaders(request, token),
body: formData,
cache: "no-store",
};
const context = {
popiPath: POPISERVER_MEDIA_UPLOAD_PATH,
mediaCount: uniqueUploadable.length,
};
const startedAt = Date.now();
logGatewayRequest("Uploading PopiServer task media", url.toString(), init, context);
const response = await fetch(url, init);
await logGatewayResponse("PopiServer task media upload response", url.toString(), init, response, context, startedAt);
const body = (await response.json().catch(() => null)) as PopiserverResponse<PopiMediaUploadData> | null;
if (!response.ok) {
throw ApiError.provider(body?.message || "PopiServer media upload failed.", response.status);
}
if (!body || body.status !== "0000") {
throw ApiError.provider(body?.message || "PopiServer media upload returned an error.");
}
const uploadedUrls = uploadedMediaUrls(body.data);
if (uploadedUrls.length !== uniqueUploadable.length) {
throw ApiError.provider("PopiServer media upload response count does not match request count.");
}
const uploadedBySource = new Map<string, string>();
uniqueUploadable.forEach((item, index) => {
uploadedBySource.set(item, uploadedUrls[index]);
});
const result = [...media];
uploadablePositions.forEach(({ item, index }) => {
result[index] = uploadedBySource.get(item) || item;
});
return result;
}
async function preparePopiTaskMedia(
request: NextRequest,
token: string,
input: GenerationInput
): Promise<GenerationInput> {
const dynamicInputs = input.dynamicInputs
? { ...input.dynamicInputs }
: undefined;
const mediaValues: string[] = [];
const assignments: Array<(uploaded: string) => void> = [];
const collect = (value: string, assign: (uploaded: string) => void): void => {
mediaValues.push(value);
assignments.push(assign);
};
const images = input.images ? [...input.images] : undefined;
images?.forEach((value, index) => {
collect(value, (uploaded) => {
images[index] = uploaded;
});
});
if (dynamicInputs) {
for (const key of POPISERVER_MEDIA_DYNAMIC_KEYS) {
const value = dynamicInputs[key];
if (typeof value === "string") {
collect(value, (uploaded) => {
dynamicInputs[key] = uploaded;
});
} else if (Array.isArray(value)) {
const values = [...value];
values.forEach((item, index) => {
collect(item, (uploaded) => {
values[index] = uploaded;
dynamicInputs[key] = values;
});
});
}
}
}
const uploadedValues = mediaValues.length > 0
? await uploadPopiMediaBatch(request, token, mediaValues)
: [];
uploadedValues.forEach((uploaded, index) => assignments[index]?.(uploaded));
return { ...input, images, dynamicInputs };
}
function getCapabilityMediaType(capabilities: ModelCapability[]): "image" | "video" | "audio" {
if (capabilities.some((capability) => capability.includes("audio"))) return "audio";
if (capabilities.some((capability) => capability.includes("video"))) return "video";
return "image";
}
function inferTaskType(input: GenerationInput): number {
const metadataType = asNumber(input.model.metadata?.type);
if (metadataType) return metadataType;
const mediaType = getCapabilityMediaType(input.model.capabilities);
if (mediaType === "video") return 2;
if (mediaType === "audio") return 3;
return 1;
}
function inferTaskSubType(input: GenerationInput, type: number): number {
const metadataSubType = asNumber(input.model.metadata?.subType);
if (metadataSubType) return metadataSubType;
const metadataSubTypes = input.model.metadata?.subTypes;
if (Array.isArray(metadataSubTypes)) {
const first = metadataSubTypes.map(asNumber).find((value): value is number => value !== null);
if (first) return first;
}
if (type === 3) return 301;
if (type === 2) {
return 203;
}
return (input.images?.length ?? 0) > 0 ? 103 : 102;
}
function resolveAspectRatio(parameters: Record<string, unknown> | undefined): string {
return asString(parameters?.ratio) ?? "16:9";
}
function dimensionsForAspectRatio(aspectRatio: string): { width: number; height: number } {
if (aspectRatio === "9:16") return { width: 720, height: 1280 };
if (aspectRatio === "1:1") return { width: 1024, height: 1024 };
return { width: 1280, height: 720 };
}
function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | null): Record<string, unknown> {
const parameters = input.parameters || {};
const type = asNumber(parameters.type) ?? inferTaskType(input);
const subType = asNumber(parameters.subType) ?? inferTaskSubType(input, type);
const aspectRatio = resolveAspectRatio(parameters);
const dimensions = dimensionsForAspectRatio(aspectRatio);
const modelCode =
asString(input.model.metadata?.aiModelCode) ??
modelDetail?.code ??
input.model.id;
const modelAlias =
asString(input.model.metadata?.aiModelCodeAlias) ??
modelDetail?.aiModelCodeAlias ??
modelCode;
const aiModelId =
asNumber(input.model.metadata?.aiModelId) ??
asNumber(input.model.id) ??
modelDetail?.id;
const images = [
...(input.images || []),
...collectDynamicMedia(input.dynamicInputs, POPISERVER_IMAGE_DYNAMIC_KEYS),
];
const videos = collectDynamicMedia(input.dynamicInputs, POPISERVER_VIDEO_DYNAMIC_KEYS);
const audios = collectDynamicMedia(input.dynamicInputs, POPISERVER_AUDIO_DYNAMIC_KEYS);
const voiceId =
firstDynamicString(input.dynamicInputs, POPISERVER_VOICE_DYNAMIC_KEYS) ??
asString(parameters.voiceId) ??
"";
const commonBody = {
type,
chatPrompt: input.prompt,
model: modelAlias,
origin: "canvas",
aiPlatform: "GATEWAY",
aiModelId,
aiModelCode: modelCode,
aiModelCodeAlias: modelAlias,
subType,
batchSize: asNumber(parameters.batchSize) ?? 1,
};
if (type === 3) {
return {
...parameters,
...commonBody,
...(audios.length > 0 ? { audios: [...new Set(audios)] } : {}),
...(voiceId ? { voiceId } : {}),
};
}
return {
...parameters,
...commonBody,
...(images.length > 0 ? { images: [...new Set(images)] } : {}),
...(videos.length > 0 ? { videos: [...new Set(videos)] } : {}),
...(audios.length > 0 ? { audios: [...new Set(audios)] } : {}),
styleId: asNumber(parameters.styleId) ?? 0,
width: asNumber(parameters.width) ?? dimensions.width,
height: asNumber(parameters.height) ?? dimensions.height,
aspectRatio,
duration: asNumber(parameters.duration) ?? undefined,
resolution: asString(parameters.resolution) ?? "720p",
};
}
async function fetchPopiJson<T>(
request: NextRequest,
token: string,
path: string,
init: RequestInit
): Promise<T> {
const url = getPopiserverBaseUrl(request) + path;
const requestInit: RequestInit = {
...init,
headers: {
...buildPopiserverForwardHeaders(request, token),
...(init.headers || {}),
},
cache: "no-store",
};
const context = {
popiPath: path,
};
const startedAt = Date.now();
logGatewayRequest("Calling PopiServer task API", url, requestInit, context);
const response = await fetch(url, requestInit);
await logGatewayResponse("PopiServer task API response", url, requestInit, response, context, startedAt);
const body = (await response.json().catch(() => null)) as PopiserverResponse<T> | null;
if (!response.ok) {
throw ApiError.provider(body?.message || "PopiServer task API request failed.", response.status);
}
if (!body || body.status !== "0000") {
throw ApiError.provider(body?.message || "PopiServer task API returned an error.");
}
return body.data as T;
}
export async function submitPopiTask(
request: NextRequest,
token: string,
input: GenerationInput
): Promise<{ taskId: string }> {
let modelDetail: PopiAIModel | null = null;
if (!input.model.metadata?.aiModelCode && !input.model.metadata?.aiModelCodeAlias) {
modelDetail = await fetchPopiModelDetail(request, token, input.model.id).catch(() => null);
}
const preparedInput = await preparePopiTaskMedia(request, token, input);
const body = buildPopiTaskBody(preparedInput, modelDetail);
const data = await fetchPopiJson<PopiTaskCreateData>(request, token, POPISERVER_TASK_CREATE_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const taskId = asString(data?.id) ?? asString(data?.taskId) ?? String(data?.id ?? data?.taskId ?? "");
if (!taskId) {
throw ApiError.provider("PopiServer task create response did not include a task id.");
}
return { taskId };
}
function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOutput {
const firstResult = Array.isArray(task.resultList) ? task.resultList[0] : null;
if (mediaType === "video") {
const url = firstResult?.video || firstResult?.cover || firstString(task.images || []) || null;
return url
? { success: true, outputs: [{ type: "video", data: "", url }] }
: { success: false, error: "PopiServer video task completed without output" };
}
if (mediaType === "audio") {
const url = firstResult?.voice || task.voice || null;
return url
? { success: true, outputs: [{ type: "audio", data: "", url }] }
: { success: false, error: "PopiServer audio task completed without output" };
}
const images = [
...new Set(collectStrings([
...(Array.isArray(task.resultList) ? task.resultList.map((result) => result.images) : []),
task.images,
])),
];
return images.length > 0
? { success: true, outputs: images.map((url) => ({ type: "image", data: "", url })) }
: { success: false, error: "PopiServer image task completed without output" };
}
export async function checkPopiTaskOnce(
request: NextRequest,
token: string,
taskId: string,
mediaType: string
): Promise<PopiTaskPollResult> {
const url = new URL(getPopiserverBaseUrl(request) + POPISERVER_TASK_LIST_PATH);
url.searchParams.set("ids", taskId);
url.searchParams.set("origin", "canvas");
const init: RequestInit = {
method: "GET",
headers: buildPopiserverForwardHeaders(request, token),
cache: "no-store",
};
const context = {
mediaType,
popiPath: POPISERVER_TASK_LIST_PATH,
};
const startedAt = Date.now();
logGatewayRequest("Polling PopiServer task API", url.toString(), init, context);
const response = await fetch(url, init);
await logGatewayResponse("PopiServer task poll response", url.toString(), init, response, context, startedAt);
const body = (await response.json().catch(() => null)) as PopiserverResponse<PopiTaskListData> | null;
if (!response.ok) {
throw ApiError.provider(body?.message || "Failed to poll PopiServer task.", response.status);
}
if (!body || body.status !== "0000") {
throw ApiError.provider(body?.message || "PopiServer task poll returned an error.");
}
const task = body.data?.list?.[0];
if (!task) return { status: "processing" };
const error = task.userErrorTipMsg || task.errorMsg;
if (error) return { status: "failed", error };
if (task.status === 2 && Array.isArray(task.resultList) && task.resultList.length > 0) {
const output = extractOutputFromTask(task, mediaType);
return output.success
? { status: "completed", output }
: { status: "failed", error: output.error || "PopiServer task completed without output" };
}
return { status: "processing" };
}