212 lines
7.1 KiB
TypeScript
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"];
|
|
}
|