Browse Source
- Upgraded to Yarn 4 to support Sharp build - Added Sharp image processing module - Added ability to select image files and one video file when posting chat message - Stubbed file processing for chat attachment uploadsdevelop
14 changed files with 9992 additions and 6616 deletions
@ -0,0 +1,12 @@ |
|||
nodeLinker: node-modules |
|||
|
|||
supportedArchitectures: |
|||
cpu: |
|||
- x64 |
|||
libc: |
|||
- glibc |
|||
os: |
|||
- darwin |
|||
- linux |
|||
|
|||
yarnPath: .yarn/releases/yarn-4.1.1.cjs |
@ -0,0 +1,66 @@ |
|||
// lib/media.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const EPISODE_STATUS_LIST = [ |
|||
'starting', // the stream is connecting
|
|||
'live', // the stream is live
|
|||
'ending', // the stream has ended, queued for processing
|
|||
'processing', // the stream is being processed for DVR
|
|||
'replay', // the stream is available on the DVR
|
|||
'expired', // the stream is expired (removed)
|
|||
]; |
|||
|
|||
const VIDEO_STATUS_LIST = [ |
|||
'new', // the video (original) is on storage / queued
|
|||
'processing', // the video is being processed for distribution
|
|||
'live', // the video is available for distribution
|
|||
'removed', // the video has been removed
|
|||
]; |
|||
|
|||
const ROOM_STATUS_LIST = [ |
|||
'starting', // the room is starting a call
|
|||
'live', // the room has a live call
|
|||
'shutdown', // the room is closing it's live call
|
|||
'expired', // the room has failed to check in
|
|||
'crashed', // the room's worker or server has crashed
|
|||
]; |
|||
|
|||
const MediaMetadataSchema = new Schema({ |
|||
type: { type: String }, |
|||
size: { type: Number }, |
|||
bitRate: { type: Number }, |
|||
duration: { type: Number }, |
|||
video: { |
|||
width: { type: Number }, |
|||
height: { type: Number }, |
|||
fps: { type: Number }, |
|||
}, |
|||
}); |
|||
|
|||
const AudioMetadataSchema = new Schema({ |
|||
type: { type: String }, |
|||
size: { type: Number }, |
|||
bitRate: { type: Number }, |
|||
duration: { type: Number }, |
|||
audio: { |
|||
codecName: { type: String }, |
|||
sampleFormat: { type: String }, |
|||
sampleRate: { type: Number }, |
|||
bitsPerSample: { type: Number }, |
|||
channelCount: { type: Number }, |
|||
}, |
|||
}); |
|||
|
|||
export { |
|||
EPISODE_STATUS_LIST, |
|||
VIDEO_STATUS_LIST, |
|||
ROOM_STATUS_LIST, |
|||
MediaMetadataSchema, |
|||
AudioMetadataSchema, |
|||
}; |
@ -0,0 +1,28 @@ |
|||
// video.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
import { |
|||
VIDEO_STATUS_LIST, |
|||
MediaMetadataSchema, |
|||
} from './lib/media.js'; |
|||
|
|||
const VideoSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: -1 }, |
|||
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
duration: { type: Number }, |
|||
thumbnail: { type: Schema.ObjectId, ref: 'Image' }, |
|||
status: { type: String, enum: VIDEO_STATUS_LIST, required: true, index: 1 }, |
|||
media: { |
|||
bucket: { type: String, required: true }, |
|||
key: { type: String, required: true }, |
|||
metadata: { type: MediaMetadataSchema }, |
|||
}, |
|||
}); |
|||
|
|||
export default mongoose.model('Video', VideoSchema); |
@ -0,0 +1,109 @@ |
|||
// media.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import util from 'node:util'; |
|||
|
|||
import { execFile, spawn } from 'node:child_process'; |
|||
const ExecFile = util.promisify(execFile); |
|||
|
|||
import { SiteService } from '../../lib/site-lib.js'; |
|||
|
|||
export default class MediaService extends SiteService { |
|||
|
|||
static get name ( ) { return 'MediaService'; } |
|||
static get slug ( ) { return 'media'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, MediaService); |
|||
} |
|||
|
|||
ffmpeg (ffmpegArgs) { |
|||
return new Promise((resolve, reject) => { |
|||
const child = spawn(process.env.DTP_FFMPEG_PATH, ffmpegArgs, { |
|||
cwd: this.dtp.config.root, |
|||
stdio: [], |
|||
}); |
|||
child.stdout.on('data', (data) => { |
|||
this.log.info(data.toString()); |
|||
}); |
|||
child.stderr.on('data', (data) => { |
|||
const message = data.toString(); |
|||
this.log.info(message); |
|||
}); |
|||
child.on('close', (code) => { |
|||
if (code !== 0) { |
|||
return reject(code); |
|||
} |
|||
return resolve(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
async ffprobe (input, options) { |
|||
options = Object.assign({ |
|||
streams: true, |
|||
format: true, |
|||
error: true, |
|||
}, options); |
|||
const ffprobeOpts = ['-print_format', 'json']; |
|||
if (options.streams) { |
|||
ffprobeOpts.push('-show_streams'); |
|||
} |
|||
if (options.format) { |
|||
ffprobeOpts.push('-show_format'); |
|||
} |
|||
if (options.error) { |
|||
ffprobeOpts.push('-show_error'); |
|||
} |
|||
ffprobeOpts.push(input); |
|||
|
|||
try { |
|||
const { stdout } = await ExecFile(process.env.DTP_FFPROBE_PATH, ffprobeOpts, { |
|||
cwd: this.dtp.config.root, |
|||
encoding: 'utf8', |
|||
}); |
|||
const probe = JSON.parse(stdout); |
|||
|
|||
if (probe.format && probe.format.tags) { |
|||
let keys = Object.keys(probe.format.tags); |
|||
keys.forEach((key) => { |
|||
probe.format.tags[key.replace(/\./g, '_')] = probe.format.tags[key]; |
|||
delete probe.format.tags[key]; |
|||
}); |
|||
} |
|||
|
|||
probe.duration = probe.format.duration; |
|||
probe.width = probe.format.width; |
|||
|
|||
if (Array.isArray(probe.streams) && probe.streams.length) { |
|||
const stream = probe.streams |
|||
.find((stream) => stream.codec_type === 'video') || probe.streams[0]; |
|||
|
|||
if (stream.duration) { |
|||
probe.duration = parseFloat(stream.duration); |
|||
} else { |
|||
probe.duration = parseFloat(probe.format.duration); |
|||
} |
|||
probe.width = stream.width; |
|||
probe.height = stream.height; |
|||
|
|||
const fpsFraction = stream.avg_frame_rate.split('/').map((value) => parseInt(value, 10)); |
|||
if (Array.isArray(fpsFraction) && fpsFraction[1] !== 0) { |
|||
probe.fps = fpsFraction[0] / fpsFraction[1]; |
|||
} |
|||
} else { |
|||
probe.duration = undefined; |
|||
probe.width = undefined; |
|||
probe.height = undefined; |
|||
} |
|||
|
|||
return probe; |
|||
} catch (error) { |
|||
this.log.error('failed to execute ffprobe', { input, error }); |
|||
throw error; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,158 @@ |
|||
// video.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import path from 'node:path'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Video = mongoose.model('Video'); |
|||
|
|||
import mime from 'mime'; |
|||
|
|||
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
|||
import user from '../models/user.js'; |
|||
|
|||
export default class VideoService extends SiteService { |
|||
|
|||
static get name ( ) { return 'VideoService'; } |
|||
static get slug ( ) { return 'video'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, VideoService); |
|||
} |
|||
|
|||
async createVideo (owner, attachmentDefinition, file) { |
|||
const NOW = new Date(); |
|||
const { media: mediaService, minio: minioService } = this.dtp.services; |
|||
|
|||
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 > (100 * 1024 * 1024)) { |
|||
throw new SiteError(403, 'Video attachments are limited to 100MB file size.'); |
|||
} |
|||
} else { |
|||
if (file.size > (25 * 1024 * 1024)) { |
|||
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'; |
|||
|
|||
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(); |
|||
|
|||
return video.toObject(); |
|||
} |
|||
|
|||
async extractVideoAttachmentThumbnail (video, file) { |
|||
const { media: mediaService } = module.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.1).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.removeThumbnailImage(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 } }); |
|||
} |
|||
} |
@ -42,6 +42,7 @@ |
|||
"jsdom": "^24.0.0", |
|||
"marked": "^12.0.1", |
|||
"mediasoup": "^3.13.24", |
|||
"mime": "^4.0.1", |
|||
"minio": "^7.1.3", |
|||
"mongoose": "^8.3.1", |
|||
"morgan": "^1.10.0", |
|||
@ -84,5 +85,6 @@ |
|||
"webpack-dev-middleware": "^7.1.1", |
|||
"webpack-stream": "^7.0.0", |
|||
"workbox-webpack-plugin": "^7.0.0" |
|||
} |
|||
}, |
|||
"packageManager": "[email protected]" |
|||
} |
|||
|
File diff suppressed because it is too large
Loading…
Reference in new issue