返回 AiToEarn
video-metadata.service.ts
根目录 / project / aitoearn-backend / libs / assets / src / video-metadata.service.ts
1 import { Injectable, Logger } from '@nestjs/common'
2 import { UserType } from '@yikart/common'
3 import { Asset, AssetRepository, AssetStatus, AssetType } from '@yikart/mongodb'
4 import { execa } from 'execa'
5 import { StorageProvider } from './storage-provider'
6 import { generateAssetPath, PathGeneratorOptions } from './utils/path-generator'
7
8 export interface VideoMetadata {
9 width: number
10 height: number
11 duration: number
12 bitrate: number
13 frameRate: number
14 }
15
16 export interface ExtractThumbnailOptions {
17 timeInSeconds?: number
18 }
19
20 @Injectable()
21 export class VideoMetadataService {
22 private readonly logger = new Logger(VideoMetadataService.name)
23
24 constructor(
25 private readonly storage: StorageProvider,
26 private readonly assetRepository: AssetRepository,
27 ) {}
28
29 async probeVideoMetadata(videoUrl: string): Promise<VideoMetadata> {
30 const { stdout } = await execa('ffprobe', [
31 '-v',
32 'quiet',
33 '-print_format',
34 'json',
35 '-show_format',
36 '-show_streams',
37 videoUrl,
38 ], {
39 timeout: 60000,
40 })
41
42 const probeData = JSON.parse(stdout)
43 const videoStream = probeData.streams?.find((s: { codec_type: string }) => s.codec_type === 'video')
44 const format = probeData.format
45
46 if (!videoStream) {
47 throw new Error('No video stream found')
48 }
49
50 const frameRateParts = (videoStream.r_frame_rate || '0/1').split('/')
51 const frameRate = frameRateParts.length === 2
52 ? Number(frameRateParts[0]) / Number(frameRateParts[1])
53 : Number(frameRateParts[0])
54
55 return {
56 width: videoStream.width || 0,
57 height: videoStream.height || 0,
58 duration: Number(format?.duration || videoStream.duration || 0),
59 bitrate: Number(format?.bit_rate || 0),
60 frameRate: Math.round(frameRate * 100) / 100,
61 }
62 }
63
64 async extractThumbnailFromUrl(videoUrl: string, timeInSeconds: number): Promise<Buffer> {
65 const { stdout } = await execa('ffmpeg', [
66 '-ss',
67 String(timeInSeconds),
68 '-i',
69 videoUrl,
70 '-vframes',
71 '1',
72 '-f',
73 'image2pipe',
74 '-vcodec',
75 'png',
76 '-',
77 ], {
78 timeout: 60000,
79 encoding: 'buffer',
80 })
81
82 return Buffer.from(stdout)
83 }
84
85 async extractAndSaveThumbnail(
86 asset: Asset,
87 userId: string,
88 userType: UserType = UserType.User,
89 options?: ExtractThumbnailOptions,
90 ): Promise<{
91 thumbnailAsset: Asset
92 thumbnailUrl: string
93 }> {
94 const timeInSeconds = options?.timeInSeconds ?? 1
95 const videoUrl = this.storage.buildUrl(asset.path)
96
97 this.logger.debug({ assetId: asset.id, videoUrl, timeInSeconds }, 'Extracting thumbnail from video')
98
99 const thumbnailBuffer = await this.extractThumbnailFromUrl(videoUrl, timeInSeconds)
100
101 const pathOptions: PathGeneratorOptions = {
102 userId,
103 userType,
104 type: AssetType.VideoThumbnail,
105 mimeType: 'image/png',
106 filename: 'thumbnail.png',
107 subPath: `source-${asset.id}`,
108 }
109
110 const thumbnailPath = generateAssetPath(pathOptions)
111
112 await this.storage.putObject(thumbnailPath, thumbnailBuffer, 'image/png')
113
114 const thumbnailAsset = await this.assetRepository.create({
115 userId,
116 userType,
117 path: thumbnailPath,
118 type: AssetType.VideoThumbnail,
119 status: AssetStatus.Confirmed,
120 size: thumbnailBuffer.length,
121 mimeType: 'image/png',
122 filename: 'thumbnail.png',
123 })
124
125 const existingMetadata = asset.metadata as Record<string, unknown> | undefined
126 const hasVideoMetadata = existingMetadata?.['width'] && existingMetadata?.['height'] && existingMetadata?.['duration']
127
128 let videoMeta: Record<string, unknown> = existingMetadata ?? {}
129 if (!hasVideoMetadata) {
130 try {
131 const probed = await this.probeVideoMetadata(videoUrl)
132 videoMeta = { ...existingMetadata, ...probed }
133 }
134 catch (error) {
135 this.logger.warn({ assetId: asset.id, error }, 'Failed to probe video metadata for thumbnail')
136 }
137 }
138
139 await this.assetRepository.updateById(asset.id, {
140 metadata: {
141 ...videoMeta,
142 cover: thumbnailPath,
143 },
144 })
145
146 this.logger.debug({ assetId: asset.id, thumbnailAssetId: thumbnailAsset.id }, 'Thumbnail extracted and saved')
147
148 return {
149 thumbnailAsset,
150 thumbnailUrl: this.storage.buildUrl(thumbnailPath),
151 }
152 }
153 }
154
154 lines TYPESCRIPT