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
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" };
|
|
}
|
|
|