// video.js // Copyright (C) 2024 DTP Technologies, LLC // All Rights Reserved 'use strict'; import fs from 'node:fs'; import path from 'node:path'; import mongoose from 'mongoose'; const Video = mongoose.model('Video'); import mime from 'mime'; import numeral from 'numeral'; import { v4 as uuidv4 } from 'uuid'; import { SiteService, SiteError } from '../../lib/site-lib.js'; import user from '../models/user.js'; const ONE_MEGABYTE = 1024 * 1024; const SIZE_100MB = ONE_MEGABYTE * 25; const SIZE_25MB = ONE_MEGABYTE * 25; const MAX_VIDEO_BITRATE = 4000; export default class VideoService extends SiteService { static get name ( ) { return 'VideoService'; } static get slug ( ) { return 'video'; } constructor (dtp) { super(dtp, VideoService); } async start ( ) { const { user: userService } = this.dtp.services; await super.start(); await fs.promises.mkdir(process.env.VIDEO_WORK_PATH, { recursive: true }); this.populateVideo = [ { path: 'owner', select: userService.USER_SELECT, }, { path: 'thumbnail', }, ]; } async createVideo (owner, videoDefinition, file) { const NOW = new Date(); const { media: mediaService, minio: minioService } = this.dtp.services; this.log.debug('video definition in createVideo', { videoDefinition }); this.log.debug('running ffprobe on uploaded library media', { path: file.path }); const metadata = await mediaService.ffprobe(file.path); this.log.debug('video file probed', { path: file.path, metadata }); if (user.membership) { if (file.size > SIZE_100MB) { throw new SiteError(403, 'Video attachments are limited to 100MB file size.'); } } else { if (file.size > SIZE_25MB) { throw new SiteError(403, 'Video attachments are limited to 25MB file size.'); } } const video = new Video(); video.created = NOW; video.owner = owner._id; video.duration = metadata.duration; video.status = 'new'; if (!videoDefinition.fromGif) { videoDefinition.fromGif = false; } video.flags = { fromGif: videoDefinition.fromGif, }; const ownerId = owner._id.toString(); const videoId = video._id.toString(); const fileBucket = process.env.MINIO_VIDEO_BUCKET || 'videos'; const mediaExt = mime.getExtension(file.mimetype) || 'dat'; const fileKey = `/video/${ownerId.slice(0, 3)}/${ownerId}/${videoId.slice(0, 3)}/${videoId}/media.${mediaExt}`; this.log.debug('uploading video to storage', { _id: video._id, bucket: fileBucket, key: fileKey, path: file.path, }); await minioService.uploadFile({ bucket: fileBucket, key: fileKey, filePath: file.path, metadata: { 'Video-ID': videoId, 'Content-Type': file.mimetype, 'Content-Length': file.size, }, }); video.media = { bucket: fileBucket, key: fileKey, metadata: { type: file.mimetype, size: metadata.format.size, bitRate: metadata.format.bit_rate, duration: metadata.duration, video: { width: metadata.width, height: metadata.height, fps: metadata.fps, }, }, }; await video.save(); await this.extractVideoAttachmentThumbnail(video, file); return video.toObject(); } async extractVideoAttachmentThumbnail (video, file) { const { media: mediaService } = this.dtp.services; const thumbnailFile = path.join(process.env.VIDEO_WORK_PATH, `${video._id}.png`); const ffmpegThumbnailArgs = [ '-y', '-i', file.path, '-ss', numeral(video.media.metadata.duration * 0.05).format('hh:mm:ss'), '-frames:v', '1', thumbnailFile, ]; this.log.debug('extracting video thumbnail', { ffmpegThumbnailArgs }); await mediaService.ffmpeg(ffmpegThumbnailArgs); this.log.info('persisting thumbnail to video', { videoId: video._id }); await this.setVideoThumbnailImage(video, thumbnailFile); } async setVideoThumbnailImage (video, thumbnailFile) { const { image: imageService } = this.dtp.services; await this.removeVideoThumbnailImage(video); const imageFileDesc = { path: thumbnailFile }; const outputs = [ { width: 960, height: 540, format: 'jpeg', formatParameters: { quality: 95 }, } ]; await imageService.processImageFile( video.owner._id, imageFileDesc, outputs ); await Video.updateOne( { _id: video._id }, { $set: { thumbnail: outputs[0].image._id } }, ); return outputs[0].image._id; } async removeVideoThumbnailImage (video) { const { image: imageService } = this.dtp.services; if (!video.thumbnail) { return; } this.log.info('removing current video thumbnail', { videoId: video._id }); await imageService.deleteImage(video.thumbnail); await Video.updateOne({ _id: video._id }, { $unset: { thumbnail: 1 } }); delete video.thumbnail; } async setVideoStatus (video, status) { await Video.updateOne({ _id: video._id }, { $set: { status } }); } async getVideoById (videoId) { const video = await Video .findOne({ _id: videoId }) .populate(this.populateVideo) .lean(); return video; } async removeVideo (video) { const { minio: minioService } = this.dtp.services; if (video.thumbnail) { await this.removeVideoThumbnailImage(video); } if (!video.media || !video.media.bucket || !video.media.key) { return; } await minioService.removeObject(video.media.bucket, video.media.key); await Video.deleteOne({ _id: video._id }); } async removeForUser (user) { this.log.info('removing all videos for user', { user: { _id: user._id, username: user.username, }, }); await Video .find({ owner: user._id }) .populate(this.populateVideo) .cursor() .eachAsync(async (video) => { await this.removeVideo(video); }); } async transcodeMov (file) { const { media: mediaService } = this.dtp.services; const probe = await mediaService.ffprobe(file.path); const videoStream = probe.streams.find((stream) => stream.codec_type === 'video'); const audioStream = probe.streams.find((stream) => stream.codec_type === 'audio'); this.log.info('Quicktime MOV probe result', { probe }); const transcodeArgs = ['-y', '-i', file.path]; if (videoStream && (videoStream.codec_name === 'h264')) { transcodeArgs.push('-c:v', 'copy'); } else { let needScale = false; /* * If the width isn't divisible by 2, adapt. */ if (probe.width && ((probe.width % 2) !== 0)) { probe.width = Math.floor(probe.width / 2) * 2; needScale = true; } /* * If the height isn't divisible by 2, that needs to be fixed. */ if ((probe.height % 2) !== 0) { probe.height = Math.floor(probe.height / 2) * 2; needScale = true; } else if (probe.height && (probe.height > 540)) { probe.height = 540; needScale = true; } if (needScale) { transcodeArgs.push('-vf', `scale=${probe.width}:${probe.height}`); } /* * Software: libx264 * GPU: h264_nvenc */ transcodeArgs.push('-pix_fmt', 'yuv420p'); transcodeArgs.push('-c:v', 'libx264'); /* * If bit rate is too high, correct it. */ probe.format.bit_rate = parseInt(probe.format.bit_rate, 10); probe.format.bit_rate = Math.round(probe.format.bit_rate / 1024); this.log.info('detected bit rate', { bitrate: probe.format.bit_rate }); if (probe.format.bit_rate > MAX_VIDEO_BITRATE) { transcodeArgs.push('-b:v', `${MAX_VIDEO_BITRATE}k`); } else { transcodeArgs.push('-b:v', `${probe.format.bit_rate}k`); } transcodeArgs.push('-profile:v', 'high'); } if (audioStream && (audioStream.codec_name === 'aac')) { transcodeArgs.push('-c:a', 'copy'); } else { transcodeArgs.push( '-c:a', 'aac', '-b:a', '160k', ); } file.uuid = uuidv4(); const outFile = path.join(process.env.VIDEO_WORK_PATH, `${file.uuid}.mp4`); transcodeArgs.push( '-movflags', '+faststart', outFile, ); this.log.info('transcoding Quicktime MOV video to MP4'); await mediaService.ffmpeg(transcodeArgs); await fs.promises.rm(file.path, { force: true }); file.path = outFile; } async transcodeGif (file) { const { media: mediaService } = this.dtp.services; const probe = await mediaService.ffprobe(file.path); this.log.info('GIF probe result', { probe }); const transcodeArgs = ['-y', '-i', file.path]; let needScale = false; /* * If the width isn't divisible by 2, adapt. */ if (probe.width && ((probe.width % 2) !== 0)) { probe.width = Math.floor(probe.width / 2) * 2; needScale = true; } /* * If the height isn't divisible by 2, adapt. */ if (probe.height && ((probe.height % 2) !== 0)) { probe.height = Math.floor(probe.height / 2) * 2; if (probe.height > 540) { probe.height = 540; } needScale = true; } else if (probe.height && (probe.height > 540)) { probe.height = 540; needScale = true; } if (needScale) { transcodeArgs.push('-vf', `scale=${probe.width}:${probe.height}`); } transcodeArgs.push('-pix_fmt', 'yuv420p'); transcodeArgs.push('-c:v', 'libx264'); /* * If bit rate is too high, correct it. */ probe.format.bit_rate = Math.round(parseInt(probe.format.bit_rate, 10) / 1024); this.log.info('detected bit rate', { bitrate: probe.format.bit_rate }); if (probe.format.bit_rate > MAX_VIDEO_BITRATE) { transcodeArgs.push('-b:v', `${MAX_VIDEO_BITRATE}k`); } else { transcodeArgs.push('-b:v', `${probe.format.bit_rate}k`); } transcodeArgs.push('-profile:v', 'high'); transcodeArgs.push('-an'); file.uuid = uuidv4(); const outFile = path.join(process.env.VIDEO_WORK_PATH, `${file.uuid}.mp4`); transcodeArgs.push('-movflags', '+faststart', outFile); this.log.info('transcoding GIF video to MP4'); await mediaService.ffmpeg(transcodeArgs); await fs.promises.rm(file.path, { force: true }); file.path = outFile; } }