311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
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"];
|
||
}
|