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