DTP Base provides a scalable and secure Node.js application development harness ready for production service.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

382 lines
10 KiB

// 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;
}
}