Files
NianAIGC/lib/server/video-generation-service.ts
2026-07-14 12:59:07 +08:00

236 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler";
import {
createAsset,
createGenerationJob,
getGenerationJob,
recordUsageEvent,
updateGenerationJob
} from "@/lib/server/data-store";
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
import { importRemoteAssetAsAsset } from "@/lib/server/storage";
import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, shouldMockSeedance, type SeedanceSettings } from "@/lib/seedance/client";
import type { GenerationJob } from "@/lib/types";
import { normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings";
import { bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, shouldMockBailian, submitBailianTask } from "@/lib/bailian/client";
export type VideoCreationEngine = "seedance" | "bailian";
export type SubmitVideoJobInput = PromptAssemblyInput & {
ownerId?: string;
externalClientId?: string;
prompt?: string;
settings?: SeedanceSettings;
engine?: VideoCreationEngine;
materials?: PromptMaterial[];
retryOf?: string;
idempotencyKey?: string;
idempotencyFingerprint?: string;
priority?: number;
maxAttempts?: number;
webhookUrl?: string;
};
export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise<GenerationJob> {
const ownerId = input.ownerId || DEFAULT_OWNER_ID;
const engine: VideoCreationEngine = input.engine === "bailian" ? "bailian" : "seedance";
const config = getSeedanceConfig();
const assembled = assemblePrompt({
...input,
mode: "video",
materials: input.materials || []
});
const finalPrompt = input.prompt?.trim() || assembled.prompt;
const settings: SeedanceSettings = { ...(input.settings || {}) };
if (engine === "bailian") {
settings.duration = normalizeVideoDuration(settings.duration) ?? 10;
settings.resolution = String(settings.resolution || "720P").toUpperCase();
buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings });
} else {
settings.ratio = normalizeVideoRatio(settings.ratio, config.ratio);
settings.duration = normalizeVideoDuration(settings.duration) ?? config.duration;
settings.resolution = normalizeVideoResolution(settings.resolution, config.model, config.resolution);
}
const mock = engine === "bailian" ? shouldMockBailian() : shouldMockSeedance();
const missingBailianKey = engine === "bailian" && !mock && !getBailianConfig().apiKey;
let job = await createGenerationJob({
ownerId,
externalClientId: input.externalClientId,
capability: "video.generate",
provider: mock ? "mock" : engine,
reqKey: engine === "bailian" ? getBailianConfig().videoModel : config.model,
status: missingBailianKey ? "failed" : "queued",
prompt: finalPrompt,
inputAssetIds: input.materials?.map((material) => material.id).filter(Boolean) as string[] || [],
inputUrls: assembled.materials.map((material) => material.url),
outputAssetIds: [],
requestPayload: {
input,
assembled,
settings,
engine
},
error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined,
retryOf: input.retryOf,
idempotencyKey: input.idempotencyKey,
idempotencyFingerprint: input.idempotencyFingerprint,
priority: input.priority,
maxAttempts: input.maxAttempts,
webhookUrl: input.webhookUrl
});
return job;
}
export async function advanceVideoJob(jobId: string, origin: string): Promise<GenerationJob> {
const job = await getGenerationJob(jobId);
if (!job) throw new Error(`Generation job not found: ${jobId}`);
if (["succeeded", "failed", "cancelled", "expired"].includes(job.status)) return job;
if (job.provider === "mock") return completeMockVideoJob(job);
if (!job.providerTaskId) return dispatchVideoJob(job, origin);
return syncVideoJob(job.id, origin);
}
async function dispatchVideoJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
try {
const input = asRecord(job.requestPayload.input) as SubmitVideoJobInput;
const assembled = asRecord(job.requestPayload.assembled);
const settings = asRecord(job.requestPayload.settings) as SeedanceSettings;
const materials = Array.isArray(assembled.materials)
? assembled.materials as PromptMaterial[]
: input.materials || [];
if (job.provider === "bailian") {
const response = await submitBailianTask("video", buildBailianVideoPayload({ prompt: job.prompt || "", settings: asRecord(settings), materials, origin }));
const providerTaskId = bailianTaskId(response);
if (!providerTaskId) throw new Error("视频任务响应缺少 task_id。");
return updateGenerationJob(job.id, { status: "running", providerTaskId, responsePayload: response });
}
const response = await createSeedanceTask({ prompt: job.prompt || "", settings, materials, origin });
return updateGenerationJob(job.id, { status: "running", providerTaskId: response.providerTaskId, responsePayload: response.raw });
} catch (error) {
job = await updateGenerationJob(job.id, {
status: "failed",
error: {
message: error instanceof Error ? error.message : String(error),
retryable: false
}
});
return job;
}
}
export async function syncVideoJob(jobId: string, origin: string): Promise<GenerationJob> {
const job = await getGenerationJob(jobId);
if (!job) throw new Error(`Generation job not found: ${jobId}`);
if (["succeeded", "failed", "cancelled", "expired"].includes(job.status)) return job;
if (job.provider === "mock") return completeMockVideoJob(job);
if (!job.providerTaskId) return job;
try {
if (job.provider === "bailian") {
const response = await queryBailianTask(job.providerTaskId);
const status = bailianStatus(response);
const resultUrl = bailianResultUrls(response, "video")[0];
if (status !== "succeeded" || !resultUrl) {
return updateGenerationJob(job.id, { status, responsePayload: response, error: status === "failed" ? { message: response.message || "百炼视频任务失败。", retryable: false } : undefined });
}
return completeRemoteVideo(job, origin, resultUrl, response);
}
const result = await querySeedanceTask(job.providerTaskId);
if (result.status !== "succeeded" || !result.resultUrl) {
return updateGenerationJob(job.id, {
status: result.status,
responsePayload: result.raw,
error: result.errorMessage ? { message: result.errorMessage, retryable: result.status === "failed" } : undefined
});
}
return completeRemoteVideo(job, origin, result.resultUrl, result.raw);
} catch (error) {
return updateGenerationJob(job.id, {
status: "failed",
error: {
message: error instanceof Error ? error.message : String(error),
retryable: false
}
});
}
}
async function completeRemoteVideo(job: GenerationJob, origin: string, resultUrl: string, responsePayload: Record<string, unknown>) {
const asset = await importRemoteAssetAsAsset({
ownerId: job.ownerId,
url: resultUrl,
origin,
source: "generated",
capability: "video.generate",
jobId: job.id,
index: 0,
fallbackContentType: "video/mp4",
tags: assetTagsForJob(job)
});
await recordUsageEvent({
ownerId: job.ownerId,
jobId: job.id,
capability: "video.generate",
quantity: 1,
estimatedUnit: "job"
});
return updateGenerationJob(job.id, {
status: "succeeded",
outputAssetIds: [asset.id],
responsePayload
});
}
export async function retryVideoJob(jobId: string, origin: string, ownerId?: string): Promise<GenerationJob> {
const job = await getGenerationJob(jobId);
if (!job) throw new Error(`Generation job not found: ${jobId}`);
if (ownerId && job.ownerId !== ownerId) throw new Error(`Generation job not found: ${jobId}`);
const input = (job.requestPayload.input || {}) as SubmitVideoJobInput;
return submitVideoJob({ ...input, ownerId: ownerId || job.ownerId, retryOf: job.id }, origin);
}
async function completeMockVideoJob(job: GenerationJob): Promise<GenerationJob> {
if (job.status === "succeeded" && job.outputAssetIds.length > 0) return job;
const asset = await createAsset({
ownerId: job.ownerId,
kind: "video",
name: `mock-video-${job.id}.mp4`,
url: "/mock/seedance-mock.mp4",
source: "generated",
tags: [...assetTagsForJob(job), "mock"],
metadata: {
mock: true,
capability: "video.generate",
jobId: job.id
}
});
await recordUsageEvent({
ownerId: job.ownerId,
jobId: job.id,
capability: "video.generate",
quantity: 1,
estimatedUnit: "job"
});
return updateGenerationJob(job.id, {
status: "succeeded",
outputAssetIds: [asset.id],
responsePayload: {
mock: true,
data: {
status: "done",
video_url: asset.url
}
}
});
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function assetTagsForJob(job: GenerationJob): string[] {
return job.externalClientId ? ["video.generate", `api-client:${job.externalClientId}`] : ["video.generate"];
}