| 1 | import type { RelayVideoCallbackDto, RelayVideoGenerationRequest, RelayVideoSubmitResponse } from '../../libs/relay/relay.interface' |
| 2 | import type { RelayVideoAiLog } from '../video-ai-log.interface' |
| 3 | import type { UserVideoGenerationRequestDto } from '../video.dto' |
| 4 | import type { VideoTaskInput } from '../video.vo' |
| 5 | import { Injectable, Optional } from '@nestjs/common' |
| 6 | import { AppException, FileUtil, ResponseCode, UserType } from '@yikart/common' |
| 7 | import { AiLogChannel, AiLogRepository, AiLogStatus, AiLogType } from '@yikart/mongodb' |
| 8 | import { TaskStatus } from '../../../../common' |
| 9 | import { AiAvailabilityService } from '../../../ai-availability/ai-availability.service' |
| 10 | import { RelayLibService } from '../../libs/relay' |
| 11 | import { ModelsConfigService } from '../../models-config' |
| 12 | import { RelayMediaResolverService } from '../../relay-media' |
| 13 | |
| 14 | @Injectable() |
| 15 | export class RelayVideoService { |
| 16 | constructor( |
| 17 | private readonly relayLibService: RelayLibService, |
| 18 | private readonly aiLogRepo: AiLogRepository, |
| 19 | private readonly modelsConfigService: ModelsConfigService, |
| 20 | private readonly aiAvailability: AiAvailabilityService, |
| 21 | @Optional() private readonly relayMediaResolver?: RelayMediaResolverService, |
| 22 | ) {} |
| 23 | |
| 24 | private async resolveRelayJson<T>(value: T): Promise<T> { |
| 25 | if (!this.relayMediaResolver) { |
| 26 | return value |
| 27 | } |
| 28 | return await this.relayMediaResolver.resolveJson(value) |
| 29 | } |
| 30 | |
| 31 | async createFromRequest(request: UserVideoGenerationRequestDto): Promise<{ id: string }> { |
| 32 | const modelConfig = this.modelsConfigService.config.video.generation.find(m => m.name === request.model) |
| 33 | if (!modelConfig) { |
| 34 | throw new AppException(ResponseCode.InvalidModel) |
| 35 | } |
| 36 | if (request.mode && !(modelConfig.modes as readonly string[]).includes(request.mode)) { |
| 37 | throw new AppException(ResponseCode.InvalidModel) |
| 38 | } |
| 39 | const startedAt = new Date() |
| 40 | |
| 41 | const payload = { ...request } as RelayVideoGenerationRequest & { userId?: string, userType?: UserType, groupId?: string } |
| 42 | delete payload.userId |
| 43 | delete payload.userType |
| 44 | delete payload.groupId |
| 45 | |
| 46 | const relayPayload = await this.resolveRelayJson(payload) |
| 47 | |
| 48 | const result = await this.aiAvailability.executeAsync<RelayVideoSubmitResponse>( |
| 49 | { provider: 'relay', operation: 'videoGeneration', model: request.model }, |
| 50 | () => this.relayLibService.createVideo(relayPayload), |
| 51 | response => response.id || '', |
| 52 | ) |
| 53 | |
| 54 | const remoteTaskId = result.id |
| 55 | if (!remoteTaskId) { |
| 56 | throw new AppException(ResponseCode.AiCallFailed, { error: 'Relay task id is missing' }) |
| 57 | } |
| 58 | |
| 59 | const logRequest: RelayVideoAiLog['request'] = { |
| 60 | ...payload, |
| 61 | remoteTaskId, |
| 62 | } |
| 63 | if (request.groupId) { |
| 64 | logRequest.groupId = request.groupId |
| 65 | } |
| 66 | |
| 67 | const aiLog = await this.aiLogRepo.create({ |
| 68 | userId: request.userId, |
| 69 | userType: request.userType, |
| 70 | taskId: remoteTaskId, |
| 71 | model: request.model, |
| 72 | channel: AiLogChannel.Relay, |
| 73 | startedAt, |
| 74 | type: AiLogType.Video, |
| 75 | request: logRequest, |
| 76 | response: { |
| 77 | ...result, |
| 78 | }, |
| 79 | status: AiLogStatus.Generating, |
| 80 | }) |
| 81 | |
| 82 | return { id: aiLog.id } |
| 83 | } |
| 84 | |
| 85 | extractInput(request: RelayVideoAiLog['request']): VideoTaskInput { |
| 86 | return { |
| 87 | prompt: request.prompt || '', |
| 88 | groupId: request.groupId, |
| 89 | image: request.image, |
| 90 | images: request.images, |
| 91 | videoUrl: request.video_url, |
| 92 | videos: request.videos, |
| 93 | audios: request.audios, |
| 94 | duration: request.duration, |
| 95 | resolution: request.resolution, |
| 96 | aspectRatio: request.ratio || (request.metadata?.['aspectRatio'] as string | undefined), |
| 97 | watermark: request.watermark, |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | getTaskResult(result: RelayVideoCallbackDto) { |
| 102 | const status = this.normalizeStatus(result.status) |
| 103 | const errorMessage = typeof result.error === 'string' |
| 104 | ? result.error |
| 105 | : result.error?.message |
| 106 | |
| 107 | return { |
| 108 | status, |
| 109 | videoUrl: result.videoUrl ? FileUtil.buildUrl(result.videoUrl) : undefined, |
| 110 | coverUrl: result.coverUrl ? FileUtil.buildUrl(result.coverUrl) : undefined, |
| 111 | error: errorMessage ? { message: errorMessage } : undefined, |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | private normalizeStatus(status: string | undefined): TaskStatus { |
| 116 | if (!status) { |
| 117 | return TaskStatus.InProgress |
| 118 | } |
| 119 | const normalized = status.toLowerCase() |
| 120 | if (['success', 'succeeded', 'completed', 'succeed'].includes(normalized)) { |
| 121 | return TaskStatus.Success |
| 122 | } |
| 123 | if (['failed', 'error', 'failure'].includes(normalized)) { |
| 124 | return TaskStatus.Failure |
| 125 | } |
| 126 | return TaskStatus.InProgress |
| 127 | } |
| 128 | |
| 129 | async callback(result: RelayVideoCallbackDto): Promise<RelayVideoCallbackDto> { |
| 130 | const taskId = result.id |
| 131 | if (!taskId) { |
| 132 | throw new AppException(ResponseCode.InvalidAiTaskId) |
| 133 | } |
| 134 | const aiLog = await this.aiLogRepo.getByTaskId(taskId) |
| 135 | if (!aiLog || aiLog.channel !== AiLogChannel.Relay) { |
| 136 | throw new AppException(ResponseCode.InvalidAiTaskId) |
| 137 | } |
| 138 | const relayAiLog = aiLog as RelayVideoAiLog |
| 139 | |
| 140 | if (relayAiLog.status !== AiLogStatus.Generating) { |
| 141 | return relayAiLog.response! |
| 142 | } |
| 143 | |
| 144 | const status = this.normalizeStatus(result.status) |
| 145 | if (status === TaskStatus.InProgress) { |
| 146 | return { ...result, id: taskId } |
| 147 | } |
| 148 | |
| 149 | const elapsedMs = Date.now() - relayAiLog.startedAt.getTime() |
| 150 | |
| 151 | if (status === TaskStatus.Success && result.videoUrl) { |
| 152 | const callbackData: RelayVideoCallbackDto = { |
| 153 | ...result, |
| 154 | id: taskId, |
| 155 | status: 'success', |
| 156 | } |
| 157 | |
| 158 | const updatedAiLog = await this.aiLogRepo.updateByIdAndStatus( |
| 159 | relayAiLog.id, |
| 160 | AiLogStatus.Generating, |
| 161 | { |
| 162 | $set: { |
| 163 | status: AiLogStatus.Success, |
| 164 | response: callbackData, |
| 165 | duration: elapsedMs, |
| 166 | }, |
| 167 | }, |
| 168 | ) |
| 169 | |
| 170 | if (!updatedAiLog) { |
| 171 | return relayAiLog.response! |
| 172 | } |
| 173 | |
| 174 | await this.aiAvailability.recordAsyncComplete( |
| 175 | taskId, |
| 176 | { provider: 'relay', operation: 'videoGeneration', model: relayAiLog.model }, |
| 177 | { success: true, latencyMs: elapsedMs }, |
| 178 | ) |
| 179 | |
| 180 | return callbackData |
| 181 | } |
| 182 | |
| 183 | const errorMessage = (typeof result.error === 'string' ? result.error : result.error?.message) |
| 184 | || (status === TaskStatus.Success ? 'Relay task completed but no video URL returned' : `Relay task ${result.status}`) |
| 185 | return this.failTask(relayAiLog, taskId, { ...result, id: taskId }, errorMessage, elapsedMs) |
| 186 | } |
| 187 | |
| 188 | private async failTask(aiLog: RelayVideoAiLog, taskId: string, callbackData: RelayVideoCallbackDto, errorMessage: string, elapsedMs: number): Promise<RelayVideoCallbackDto> { |
| 189 | const updatedAiLog = await this.aiLogRepo.updateByIdAndStatus( |
| 190 | aiLog.id, |
| 191 | AiLogStatus.Generating, |
| 192 | { |
| 193 | $set: { |
| 194 | status: AiLogStatus.Failed, |
| 195 | response: callbackData, |
| 196 | duration: elapsedMs, |
| 197 | errorMessage, |
| 198 | }, |
| 199 | }, |
| 200 | ) |
| 201 | |
| 202 | if (!updatedAiLog) { |
| 203 | return aiLog.response! |
| 204 | } |
| 205 | |
| 206 | await this.aiAvailability.recordAsyncComplete( |
| 207 | taskId, |
| 208 | { provider: 'relay', operation: 'videoGeneration', model: aiLog.model }, |
| 209 | { success: false, latencyMs: elapsedMs, errorMessage }, |
| 210 | ) |
| 211 | |
| 212 | return callbackData |
| 213 | } |
| 214 | } |
| 215 |