Files
NianAIGC/lib/server/video-generation-service.ts

311 lines
12 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,
recordUsageForJob,
updateGenerationJob
} from "@/lib/server/data-store";
import { chargeGenerationJob, quoteGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service";
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 { BillingJobCharge, GenerationJob, UsageContext } 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;
usageContext?: UsageContext;
};
type PreparedVideoGeneration = {
ownerId: string;
engine: VideoCreationEngine;
config: ReturnType<typeof getSeedanceConfig>;
assembled: ReturnType<typeof assemblePrompt>;
finalPrompt: string;
settings: SeedanceSettings;
missingBailianKey: boolean;
provider: "mock" | "bailian" | "seedance";
reqKey: string;
requestPayload: Record<string, unknown>;
};
export async function quoteVideoGeneration(input: SubmitVideoJobInput, origin: string): Promise<BillingJobCharge | undefined> {
const prepared = prepareVideoGeneration(input, origin);
if (prepared.missingBailianKey) return undefined;
return quoteGenerationCharge({
provider: prepared.provider,
capability: "video.generate",
reqKey: prepared.reqKey,
requestPayload: prepared.requestPayload,
usageContext: input.usageContext,
externalClientId: input.externalClientId,
allowUnboundOrganization: true
});
}
export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise<GenerationJob> {
const prepared = prepareVideoGeneration(input, origin);
const { ownerId, engine, config, assembled, finalPrompt, settings, missingBailianKey, provider, reqKey, requestPayload } = prepared;
const billing = missingBailianKey ? undefined : await quoteGenerationCharge({
provider,
capability: "video.generate",
reqKey,
requestPayload,
usageContext: input.usageContext,
externalClientId: input.externalClientId
});
let job = await createGenerationJob({
ownerId,
externalClientId: input.externalClientId,
capability: "video.generate",
provider,
reqKey,
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,
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,
usageContext: input.usageContext,
billing
});
if (!billing) return job;
try {
return await chargeGenerationJob(job);
} catch (error) {
await updateGenerationJob(job.id, {
status: "failed",
billing: { ...billing, status: "not_charged" },
error: {
message: error instanceof Error ? error.message : String(error),
retryable: false
}
}).catch(() => undefined);
throw error;
}
}
function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): PreparedVideoGeneration {
const ownerId = input.ownerId || DEFAULT_OWNER_ID;
const configuredEngine = process.env.VIDEO_GENERATE_ENGINE === "seedance" ? "seedance" : "bailian";
const engine: VideoCreationEngine = input.engine === "seedance" || input.engine === "bailian"
? input.engine
: configuredEngine;
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;
const provider = mock ? "mock" : engine;
const requestPayload = {
input,
assembled,
settings,
engine
};
return {
ownerId,
engine,
config,
assembled,
finalPrompt,
settings,
missingBailianKey,
provider,
reqKey: engine === "bailian" ? getBailianConfig().videoModel : config.model,
requestPayload
};
}
export async function advanceVideoJob(jobId: string, origin: string): Promise<GenerationJob> {
let 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.billing?.status === "pending") job = await chargeGenerationJob(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, result.usage?.completionTokens);
} 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>,
completionTokens?: number
) {
const settledJob = job.provider === "seedance"
? await settleSeedanceGenerationCharge(job, completionTokens) || job
: job;
const asset = await importRemoteAssetAsAsset({
ownerId: settledJob.ownerId,
url: resultUrl,
origin,
source: "generated",
capability: "video.generate",
jobId: settledJob.id,
index: 0,
fallbackContentType: "video/mp4",
tags: assetTagsForJob(settledJob)
});
await recordUsageForJob(settledJob);
return updateGenerationJob(settledJob.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,
usageContext: job.usageContext || input.usageContext,
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
}
});
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"];
}