Files
NianAIGC/lib/server/video-generation-service.ts
2026-05-29 15:54:13 +08:00

212 lines
7.1 KiB
TypeScript

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";
export type SubmitVideoJobInput = PromptAssemblyInput & {
ownerId?: string;
externalClientId?: string;
prompt?: string;
settings?: SeedanceSettings;
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 config = getSeedanceConfig();
const assembled = assemblePrompt({
...input,
mode: "video",
materials: input.materials || []
});
const finalPrompt = input.prompt?.trim() || assembled.prompt;
const settings: SeedanceSettings = { ...(input.settings || {}) };
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 = shouldMockSeedance();
let job = await createGenerationJob({
ownerId,
externalClientId: input.externalClientId,
capability: "video.generate",
provider: mock ? "mock" : "seedance",
reqKey: config.model,
status: "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
},
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 || [];
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 {
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
});
}
const asset = await importRemoteAssetAsAsset({
ownerId: job.ownerId,
url: result.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: result.raw
});
} catch (error) {
return updateGenerationJob(job.id, {
status: "failed",
error: {
message: error instanceof Error ? error.message : String(error),
retryable: false
}
});
}
}
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"];
}