| 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 |