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", |
"jsdom": "^24.0.0", |
||||
"marked": "^12.0.1", |
"marked": "^12.0.1", |
||||
"mediasoup": "^3.13.24", |
"mediasoup": "^3.13.24", |
||||
|
"mime": "^4.0.1", |
||||
"minio": "^7.1.3", |
"minio": "^7.1.3", |
||||
"mongoose": "^8.3.1", |
"mongoose": "^8.3.1", |
||||
"morgan": "^1.10.0", |
"morgan": "^1.10.0", |
||||
@ -84,5 +85,6 @@ |
|||||
"webpack-dev-middleware": "^7.1.1", |
"webpack-dev-middleware": "^7.1.1", |
||||
"webpack-stream": "^7.0.0", |
"webpack-stream": "^7.0.0", |
||||
"workbox-webpack-plugin": "^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