Browse Source

many upgrades and features

- 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 uploads
develop
Rob Colbert 1 year ago
parent
commit
ba3e4a2a34
  1. 1
      .gitignore
  2. 12
      .yarnrc.yml
  3. 4
      app/controllers/chat.js
  4. 4
      app/models/chat-message.js
  5. 66
      app/models/lib/media.js
  6. 28
      app/models/video.js
  7. 2
      app/services/chat.js
  8. 3
      app/services/host-cache.js
  9. 109
      app/services/media.js
  10. 158
      app/services/video.js
  11. 11
      app/views/chat/room/view.pug
  12. 2
      lib/site-runtime.js
  13. 4
      package.json
  14. 16204
      yarn.lock

1
.gitignore

@ -1,4 +1,5 @@
.env
.yarn
node_modules
data/minio

12
.yarnrc.yml

@ -0,0 +1,12 @@
nodeLinker: node-modules
supportedArchitectures:
cpu:
- x64
libc:
- glibc
os:
- darwin
- linux
yarnPath: .yarn/releases/yarn-4.1.1.cjs

4
app/controllers/chat.js

@ -44,7 +44,7 @@ export default class ChatController extends SiteController {
router.post(
'/room/:roomId/message',
multer.none(),
multer.fields([{ name: 'imageFiles', maxCount: 6 }, { name: 'videoFile', maxCount: 1 }]),
this.postRoomMessage.bind(this),
);
@ -96,7 +96,7 @@ export default class ChatController extends SiteController {
async postRoomMessage (req, res) {
const { chat: chatService } = this.dtp.services;
try {
await chatService.sendRoomMessage(res.locals.room, req.user, req.body);
await chatService.sendRoomMessage(res.locals.room, req.user, req.body, req.imageFiles, req.videoFiles);
return res.status(200).json({ success: true });
} catch (error) {
this.log.error('failed to send chat room message', { error });

4
app/models/chat-message.js

@ -19,6 +19,10 @@ const ChatMessageSchema = new Schema({
mentions: { type: [Schema.ObjectId], select: false, ref: 'User' },
hashtags: { type: [String], select: false },
links: { type: [Schema.ObjectId], ref: 'Link' },
attachments: {
images: { type: [Schema.ObjectId], ref: 'Image' },
videos: { type: [Schema.ObjectId], ref: 'Video' },
},
});
export default mongoose.model('ChatMessage', ChatMessageSchema);

66
app/models/lib/media.js

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

28
app/models/video.js

@ -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);

2
app/services/chat.js

@ -208,7 +208,7 @@ export default class ChatService extends SiteService {
this.dtp.emitter.to(room._id.toString()).emit('chat-control', { displayList, systemMessages: [systemMessage] });
}
async sendRoomMessage (room, author, messageDefinition) {
async sendRoomMessage (room, author, messageDefinition, imageFiles, videoFiles) {
const { text: textService, user: userService } = this.dtp.services;
const NOW = new Date();

3
app/services/host-cache.js

@ -130,6 +130,9 @@ export default class HostCacheService extends SiteService {
if ((error.errno !== -111) || (error.code !== 'ECONNREFUSED')) {
return;
}
if (!this.transactions) {
return;
}
for (const key of this.transactions) {
this.log.alert('destroying host cache transaction', { key });
const transaction = this.transactions[key];

109
app/services/media.js

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

158
app/services/video.js

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

11
app/views/chat/room/view.pug

@ -76,7 +76,7 @@ block view-content
action=`/chat/room/${room._id}/message`,
id="chat-input-form",
data-room-id= room._id,
onsubmit="return window.dtp.app.sendUserChat(event);",
onsubmit="return window.dtp.app.sendChatRoomMessage(event);",
hidden= user && user.flags && user.flags.isCloaked,
enctype="multipart/form-data"
).uk-form
@ -84,6 +84,15 @@ block view-content
.uk-margin-small
.uk-flex
.uk-width-expand
div(uk-grid).uk-grid-small
.uk-width-auto
.uk-form-custom
input(id="image-files", name="imageFiles[]", type="file")
button(type="button").uk-button.uk-button-default Image
.uk-width-auto
.uk-form-custom
input(id="video-file", name="videoFile", type="file")
button(type="button").uk-button.uk-button-default Video
.uk-width-auto
button(id="chat-send-btn", type="submit", uk-tooltip={ title: 'Send message' }).uk-button.uk-button-primary.uk-button-small.uk-border-rounded.uk-light
i.fas.fa-paper-plane

2
lib/site-runtime.js

@ -233,7 +233,7 @@ export class SiteRuntime {
this.services[ServiceClass.slug] = service;
inits.push(service);
} catch (error) {
this.log.error('failed to load service', { error });
this.log.error('failed to load service', { script: entry, error });
throw new Error('failed to load service', { cause: error });
}
}

4
package.json

@ -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]"
}

16204
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save