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 = { 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 | 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 | 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 { 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 | 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(); 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 { 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 | 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 { 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( request: NextRequest, token: string, path: string, init: RequestInit ): Promise { 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 | 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(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 { 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 | 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" }; }