Files
NianAIGC/lib/server/public-api-jobs.ts
2026-05-29 14:32:02 +08:00

158 lines
5.9 KiB
TypeScript

import { createHash } from "node:crypto";
import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler";
import { findGenerationJobByIdempotency } from "@/lib/server/data-store";
import { submitImageJob, type SubmitImageJobInput } from "@/lib/server/generation-service";
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service";
import type { PublicApiClient } from "@/lib/server/public-api-auth";
import type { EnabledImageCapability, GenerationCapability, GenerationJob } from "@/lib/types";
export class PublicApiConflictError extends Error {
status = 409;
constructor(message: string) {
super(message);
this.name = "PublicApiConflictError";
}
}
export type PublicJobCreateBody = {
capability?: GenerationCapability;
prompt?: string;
inputUrls?: string[];
imageUrls?: string[];
inputAssetIds?: string[];
materials?: PromptMaterial[];
promptAssembly?: PromptAssemblyInput;
settings?: SubmitVideoJobInput["settings"];
priority?: number;
webhookUrl?: string;
idempotencyKey?: string;
scale?: number;
width?: number;
height?: number;
min_ratio?: number;
max_ratio?: number;
force_single?: boolean;
resolution?: "4k" | "8k";
quality?: string;
seed?: number;
};
export async function createPublicGenerationJob(input: {
client: PublicApiClient;
body: PublicJobCreateBody;
request: Request;
origin: string;
}): Promise<{ job: GenerationJob; reused: boolean }> {
const capability = input.body.capability || "image.generate";
const idempotencyKey = input.request.headers.get("idempotency-key") || input.body.idempotencyKey;
const fingerprint = idempotencyKey ? fingerprintBody(input.body) : undefined;
if (idempotencyKey && fingerprint) {
const existing = await findGenerationJobByIdempotency(input.client.id, idempotencyKey);
if (existing) {
if (existing.idempotencyFingerprint !== fingerprint) {
throw new PublicApiConflictError("Idempotency key was already used with a different request body.");
}
return { job: existing, reused: true };
}
}
const common = {
ownerId: DEFAULT_OWNER_ID,
externalClientId: input.client.id,
idempotencyKey,
idempotencyFingerprint: fingerprint,
priority: normalizePriority(input.body.priority),
webhookUrl: normalizeWebhookUrl(input.body.webhookUrl),
maxAttempts: 3
};
if (capability === "video.generate") {
const job = await submitVideoJob({
...input.body,
...common,
mode: "video",
materials: input.body.materials || input.body.promptAssembly?.materials || []
} as SubmitVideoJobInput, input.origin);
return { job, reused: false };
}
const imageCapability = normalizeImageCapability(capability);
const assembled = input.body.promptAssembly
? assemblePrompt({
...input.body.promptAssembly,
mode: "image",
materials: input.body.materials || input.body.promptAssembly.materials || []
})
: undefined;
const materialImages = (input.body.materials || assembled?.materials || [])
.filter((material) => material.type === "image")
.map((material) => material.url);
const job = await submitImageJob({
...common,
capability: imageCapability,
prompt: input.body.prompt || assembled?.prompt,
imageUrls: input.body.imageUrls || input.body.inputUrls || materialImages,
inputAssetIds: input.body.inputAssetIds || (input.body.materials || []).map((material) => material.id).filter(Boolean) as string[],
scale: asNumber(input.body.scale),
width: asNumber(input.body.width),
height: asNumber(input.body.height),
min_ratio: asNumber(input.body.min_ratio),
max_ratio: asNumber(input.body.max_ratio),
force_single: Boolean(input.body.force_single),
resolution: input.body.resolution,
quality: normalizeQuality(input.body.quality),
seed: asNumber(input.body.seed)
} satisfies SubmitImageJobInput, input.origin);
return { job, reused: false };
}
function normalizeImageCapability(capability: GenerationCapability): EnabledImageCapability {
if (capability === "image.generate" || capability === "image.inpaint" || capability === "image.upscale") {
return capability;
}
throw new Error(`Unsupported image capability: ${capability}`);
}
function normalizePriority(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return Math.max(-100, Math.min(100, Math.trunc(parsed)));
}
function normalizeWebhookUrl(value: unknown): string | undefined {
if (typeof value !== "string" || !value.trim()) return undefined;
const url = new URL(value.trim());
if (!["http:", "https:"].includes(url.protocol)) throw new Error("webhookUrl must be an HTTP or HTTPS URL.");
return url.toString();
}
function asNumber(value: unknown): number | undefined {
if (value === undefined || value === null || value === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function normalizeQuality(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
return ["low", "medium", "high"].includes(normalized) ? normalized : undefined;
}
function fingerprintBody(body: PublicJobCreateBody): string {
const { idempotencyKey: _idempotencyKey, ...fingerprintSource } = body;
return createHash("sha256").update(stableStringify(fingerprintSource)).digest("hex");
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}