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 { 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 { 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 { 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 { 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): Promise { const job = await getGenerationJob(jobId); if (!job) throw new Error(`Generation job not found: ${jobId}`); const input = (job.requestPayload.input || {}) as SubmitVideoJobInput; return submitVideoJob({ ...input, retryOf: job.id }, origin); } async function completeMockVideoJob(job: GenerationJob): Promise { 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 { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } function assetTagsForJob(job: GenerationJob): string[] { return job.externalClientId ? ["video.generate", `api-client:${job.externalClientId}`] : ["video.generate"]; }