|
| 1 | +import child_process from "node:child_process"; |
| 2 | +import os from "node:os"; |
| 3 | +import path from "node:path"; |
| 4 | +import fs from "node:fs"; |
| 5 | + |
| 6 | +import { EventEmitter } from "node:events"; // TODO remove if unnecessary |
| 7 | +import { Logger } from "#src/utils/utils.js"; |
| 8 | +import { STREAM_TYPE } from "#src/shared/enums.js"; |
| 9 | + |
| 10 | +const logger = new Logger("RECORDER"); |
| 11 | +const temp = os.tmpdir(); |
| 12 | +const FORMAT = "mp4"; // TODO config |
| 13 | +const VIDEO_LIMIT = 4; // TODO config (and other name?) |
| 14 | + |
| 15 | +/** |
| 16 | + * @typedef {Object} RTPTransports |
| 17 | + * @property {Array<import("mediasoup").types.Transport>} audio |
| 18 | + * @property {Array<import("mediasoup").types.Transport>} camera |
| 19 | + * @property {Array<import("mediasoup").types.Transport>} screen |
| 20 | + */ |
| 21 | + |
| 22 | +/** |
| 23 | + * Wraps the FFMPEG process |
| 24 | + * TODO move in own file |
| 25 | + */ |
| 26 | +class FFMPEG extends EventEmitter { |
| 27 | + /** @type {child_process.ChildProcess} */ |
| 28 | + _process; |
| 29 | + /** @type {string} */ |
| 30 | + _filePath; |
| 31 | + |
| 32 | + get _args() { |
| 33 | + return [ |
| 34 | + "-loglevel", |
| 35 | + "debug", // TODO warning in prod |
| 36 | + "-protocol_whitelist", |
| 37 | + "pipe,udp,rtp", |
| 38 | + "-fflags", |
| 39 | + "+genpts", |
| 40 | + "-f", |
| 41 | + "sdp", |
| 42 | + "-i", |
| 43 | + "pipe:0", |
| 44 | + "-movflags", |
| 45 | + "frag_keyframe+empty_moov+default_base_moof", // fragmented |
| 46 | + "-c:v", |
| 47 | + "libx264", // vid codec |
| 48 | + "-c:a", |
| 49 | + "aac", // audio codec |
| 50 | + "-f", |
| 51 | + FORMAT, |
| 52 | + this._filePath, |
| 53 | + ]; |
| 54 | + } |
| 55 | + |
| 56 | + /** |
| 57 | + * @param {string} filePath |
| 58 | + */ |
| 59 | + constructor(filePath) { |
| 60 | + super(); |
| 61 | + this._filePath = filePath; |
| 62 | + } |
| 63 | + |
| 64 | + /** |
| 65 | + * @param {String[]} [sdp] |
| 66 | + */ |
| 67 | + async spawn(sdp) { |
| 68 | + this._process = child_process.spawn("ffmpeg", this._args, { |
| 69 | + stdio: ["pipe", "pipe", process.stderr], |
| 70 | + }); |
| 71 | + |
| 72 | + if (!this._process.stdin.writable) { |
| 73 | + throw new Error("FFMPEG stdin not writable."); |
| 74 | + } |
| 75 | + this._process.stdin.write(sdp); |
| 76 | + this._process.stdin.end(); |
| 77 | + |
| 78 | + this._process.stdout.on("data", (chunk) => { |
| 79 | + this.emit("data", chunk); // Emit data chunks as they become available |
| 80 | + }); |
| 81 | + |
| 82 | + this._process.on("close", (code) => { |
| 83 | + if (code === 0) { |
| 84 | + this.emit("success"); |
| 85 | + } |
| 86 | + }); |
| 87 | + |
| 88 | + logger.debug( |
| 89 | + `FFMPEG process (pid:${this._process.pid}) spawned, outputting to ${this._filePath}` |
| 90 | + ); |
| 91 | + } |
| 92 | + |
| 93 | + kill() { |
| 94 | + this._process?.kill("SIGINT"); |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +export class Recorder extends EventEmitter { |
| 99 | + static records = new Map(); |
| 100 | + |
| 101 | + /** @type {string} */ |
| 102 | + uuid = crypto.randomUUID(); |
| 103 | + /** @type {import("#src/models/channel").Channel} */ |
| 104 | + channel; |
| 105 | + /** @type {string} */ |
| 106 | + state; |
| 107 | + ffmpeg; |
| 108 | + /** @type {RTPTransports} */ |
| 109 | + _rtpTransports; |
| 110 | + /** @type {string} */ |
| 111 | + filePath; |
| 112 | + /** |
| 113 | + * @param {import("#src/models/channel").Channel} channel |
| 114 | + */ |
| 115 | + constructor(channel) { |
| 116 | + super(); |
| 117 | + this.channel = channel; |
| 118 | + this.filePath = path.join(temp, `${this.uuid}.${FORMAT}`); |
| 119 | + Recorder.records.set(this.uuid, this); |
| 120 | + } |
| 121 | + |
| 122 | + /** @returns {number} */ |
| 123 | + get videoCount() { |
| 124 | + return this._rtpTransports.camera.length + this._rtpTransports.screen.length; |
| 125 | + } |
| 126 | + |
| 127 | + /** |
| 128 | + * @param {Array} ids |
| 129 | + * @returns {string} filePath |
| 130 | + */ |
| 131 | + start(ids) { |
| 132 | + // maybe internal state and check if already recording (recording = has ffmpeg child process). |
| 133 | + this.stop(); |
| 134 | + for (const id of ids) { |
| 135 | + const session = this.channel.sessions.get(id); |
| 136 | + const audioRtp = this._createRtp( |
| 137 | + session.producers[STREAM_TYPE.AUDIO], |
| 138 | + STREAM_TYPE.AUDIO |
| 139 | + ); |
| 140 | + audioRtp && this._rtpTransports.audio.push(audioRtp); |
| 141 | + for (const type in [STREAM_TYPE.CAMERA, STREAM_TYPE.SCREEN]) { |
| 142 | + if (this.videoCount < VIDEO_LIMIT) { |
| 143 | + const rtp = this._createRtp(session.producers[type], type); |
| 144 | + rtp && this._rtpTransports[type].push(rtp); |
| 145 | + } |
| 146 | + } |
| 147 | + } |
| 148 | + this.ffmpeg = new FFMPEG(this.filePath); |
| 149 | + this.ffmpeg.spawn(); // args should be base on the rtp transports |
| 150 | + this.ffmpeg.once("success", () => { |
| 151 | + this.emit("download-ready", this.filePath); |
| 152 | + }); |
| 153 | + return this.filePath; |
| 154 | + } |
| 155 | + pause() { |
| 156 | + // TODO maybe shouldn't be able to pause |
| 157 | + } |
| 158 | + stop() { |
| 159 | + // TODO |
| 160 | + // cleanup all rtp transports |
| 161 | + // stop ffmpeg process |
| 162 | + Recorder.records.delete(this.uuid); |
| 163 | + } |
| 164 | + |
| 165 | + /** |
| 166 | + * @param {http.ServerResponse} res |
| 167 | + */ |
| 168 | + pipeToResponse(res) { |
| 169 | + // TODO check if this can be executed, otherwise end request? |
| 170 | + const fileStream = fs.createReadStream(this._filePath); |
| 171 | + res.writeHead(200, { |
| 172 | + "Content-Type": `video/${FORMAT}`, |
| 173 | + "Content-Disposition": "inline", |
| 174 | + }); |
| 175 | + fileStream.pipe(res); // Pipe the file stream to the response |
| 176 | + } |
| 177 | + |
| 178 | + /** |
| 179 | + * @param {import("mediasoup").types.Producer} producer |
| 180 | + * @param {STREAM_TYPE[keyof STREAM_TYPE]} type |
| 181 | + * @return {Promise<void>} probably just create transport with right ports and return that, |
| 182 | + */ |
| 183 | + async _createRtp(producer, type) { |
| 184 | + // TODO |
| 185 | + } |
| 186 | +} |
0 commit comments