UNPKG

web-video-creator

Version:

A framework for creating videos based on Node.js + Puppeteer + FFmpeg.

458 lines (429 loc) 15.8 kB
import os from "os"; import path from "path"; import crypto from 'crypto'; import assert from "assert"; import { Writable } from 'stream'; import { fileURLToPath } from "url"; import fs from "fs-extra"; import CRC32 from "crc-32"; import ffmpeg from "fluent-ffmpeg"; import got from "got"; import _ from "lodash"; import globalConfig from "./global-config.js"; import logger from "./logger.js"; // 项目根路径 const ROOT_PATH = path.dirname(path.dirname(fileURLToPath(import.meta.url))); export default { /** * 拼接路径 * * @param {string} relativePath - 相对路径 * @returns {string} - 绝对路径 */ rootPathJoin(relativePath) { if (!relativePath) return relativePath; // 如果是绝对路径则直接返回绝对路径 if (path.isAbsolute(relativePath)) return relativePath; return path.join(ROOT_PATH, relativePath); }, /** * 检查远端资源是否可访问 * * @param {string} url - 资源URL * @param {string[]|RegExp[]} [mimesLimit] - MIME类型限制列表 */ async checkRemoteResource(url, mimesLimit) { url = url.replace("localhost", "127.0.0.1"); const response = await got.head(url); if (response.statusCode >= 400) throw new Error(`Resource ${url} request error: [${response.statusCode || 0}] ${response.statusMessage || "Unknown"}`); const mime = response.headers["content-type"] || "unknown"; const size = response.headers["content-length"]; if (_.isArray(mimesLimit)) { let matched; for (let limit of mimesLimit) { if (limit == mime || (_.isRegExp(limit) && limit.test(mime))) { matched = true; break; } } if (!matched) throw new Error(`Resource ${url} content type ${mime} is not supported`); } return { mime, size: size ? Number(size) : null }; }, /** * 获取媒体元数据 * * @param {string} source - 媒体来源 * @returns {Object} - 媒体元数据 */ async getMediaMetadata(source) { if (!await fs.pathExists(source) && !this.isURL(source)) throw new Error(`media source ${source} not found`); return new Promise((resolve, reject) => { ffmpeg.ffprobe(source, (err, metadata) => { if (err) return reject(err); resolve(metadata); }); }); }, /** * 获取媒体视频编码器 * * @param {string} source - 媒体来源 * @param {boolean} allStreams - 是否返回所有流的编码器信息 * @returns */ async getMediaVideoCodecName(source, allStreams = false) { const { streams } = await this.getMediaMetadata(source); const videoStreams = streams.filter(v => v.codec_type === "video"); if (!videoStreams.length) throw new Error(`${source} video stream not found`); return allStreams ? videoStreams.map(stream => stream.codec_name) : videoStreams[0].codec_name; }, /** * 获取媒体时长 * * @param {string} source - 媒体来源 * @returns {number} - 媒体时长(毫秒) */ async getMediaDuration(source) { const metadata = await this.getMediaMetadata(source); //获取媒体信息 if (!metadata || !metadata.format || !_.isFinite(metadata.format.duration)) throw new Error(`Media ${source} duration invalid`); return (metadata.format.duration || 0) * 1000; }, /** * 检查媒体是否具有透明通道 * * @param {string} source - 媒体来源 * @returns {boolean} = 是否具有透明通道 */ async checkMediaHasAplhaChannel(source) { const metadata = await this.getMediaMetadata(source); //获取媒体信息 if (!metadata || !metadata.streams || !metadata.streams[0]) throw new Error(`Media ${source} streams invalid`); if (!metadata.streams[0].tags || !metadata.streams[0].tags["ALPHA_MODE"]) return false; return Number(metadata.streams[0].tags["ALPHA_MODE"]) > 0; }, /** * 判断是否URL * * @param {string} value - 检查值 * @returns {boolean} - 是否URL */ isURL(value) { return !_.isUndefined(value) && /^(http|https)/.test(value); }, /** * 判断是否处于Linux平台 * * @returns {boolean} - 是否Linux平台 */ isLinux() { return os.platform() !== "win32"; }, /** * 判断是否写入流 * * @param {*} value - 值 * @returns {boolean} - 是否写入流 */ isWriteStream(value) { return value && (value instanceof Writable || "writable" in value || value.writable); }, /** * 拼接URL * * @param {...any} values - 字符串 * @returns {string} - URL */ urlJoin(...values) { let url = ""; for (let i = 0; i < values.length; i++) url += `${i > 0 ? "/" : ""}${values[i].replace(/^\/*/, "").replace(/\/*$/, "")}`; return url; }, /** * URL转本地路径 * * @param {string} value - URL * @returns {string} - 路径 */ urlToPath(value) { const { host, pathname } = new URL(value); return `${host.replace(/\.|:/g, "_")}${pathname.replace(/\.\.|:|@|\?|\*/g, "_")}` }, /** * 获取URL扩展名 * * @param {string} value - URL * @returns {string} - 扩展名 */ getURLExtname(value) { if (!this.isURL(value)) return null; const { pathname } = new URL(value); const extname = path.extname(pathname); if (!extname) return null; return extname.substring(1); }, /** * 获取路径扩展名 * * @param {string} value - 路径 * @returns {string} - 扩展名 */ getPathExtname(value) { return path.extname(value).substring(1); }, /** * 毫秒转H:m:s.ms时间字符串 * * @param {number} milliseconds - 毫秒 * @returns {string} - 时间字符串 */ millisecondsToHmss(milliseconds) { if (_.isString(milliseconds)) return milliseconds; milliseconds = parseInt(milliseconds); const sec = Math.floor(milliseconds / 1000); const hours = Math.floor(sec / 3600); const minutes = Math.floor((sec - hours * 3600) / 60); const seconds = sec - hours * 3600 - minutes * 60; const ms = milliseconds % 60000 - seconds * 1000; return `${hours > 9 ? hours : "0" + hours}:${minutes > 9 ? minutes : "0" + minutes}:${seconds > 9 ? seconds : "0" + seconds}.${ms}`; }, /** * 将时长转换为总帧数 * * @param {number} duration - 时长 * @param {number} fps - 帧率 * @returns {number} - 总帧数 */ durationToFrameCount(duration, fps) { assert(_.isFinite(duration), "duration must be number"); assert(_.isFinite(fps), "fps must be number"); return Math.floor(duration / 1000 * fps) }, /** * 将总帧数转换为时长 * * @param {number} frameCount - 总帧数 * @param {number} fps - 帧率 * @returns {number} - 时长 */ frameCountToDuration(frameCount, fps) { assert(_.isFinite(frameCount), "duration must be number"); assert(_.isFinite(fps), "fps must be number"); return frameCount / fps; }, /** * 从视频捕获截图 * * @param {string} source - 输入源 * @param {string} dest - 输出源 * @param {number} timemark - 时间点 */ async captureScreenshot(source, dest, timemark) { return new Promise((resolve, reject) => { ffmpeg(source) .screenshot({ folder: path.dirname(dest), filename: path.basename(dest), timemarks: [this.millisecondsToHmss(timemark)] }) .once("error", reject) .once("end", resolve); }); }, /** * 从视频分离音轨 * * @param {string} source 视频来源 * @param {string} dest 输出目标 * @param {Object} [options] - 输出选项 * @param {number} [options.seekStart] - 裁剪开始时间点 * @param {number} [options.seekEnd] - 裁剪结束时间点 * @param {string} [options.audioEncoder="aac"] - 音频编码器 * @param {string} [options.audioBitrate="320k"] - 音频码率 * @param {string} [options.audioSampleRate="44100"] - 音频采样率 * @param {string} [options.outputFormat="aac"] - 输出格式 * @returns {boolean} - 是否已分离音频 */ async separateVideoAudioTrack(source, dest, options = {}) { assert(_.isObject(options), "options must be Object"); const { seekStart, seekEnd, audioEncoder = "libmp3lame", audioBitrate = "320k", audioSampleRate = "44100", outputFormat = "mp3" } = options; assert(_.isString(source), "source must be an url or path"); assert(_.isString(dest), "dest must be an path"); const acodeer = ffmpeg(); acodeer.addInput(source); _.isFinite(seekStart) && acodeer.addInputOption("-ss", this.millisecondsToHmss(seekStart)); _.isFinite(seekEnd) && seekEnd > (seekStart || 0) && acodeer.addInputOption("-to", this.millisecondsToHmss(seekEnd)); let audioDuration; if (_.isFinite(seekEnd)) audioDuration = seekEnd - (seekStart || 0); else audioDuration = (await this.getMediaDuration(source)) - (seekStart || 0); return new Promise((resolve, reject) => { acodeer .addInputOption("-vn") .complexFilter("[0]apad") .setDuration(audioDuration / 1000) .audioCodec(audioEncoder) .audioBitrate(audioBitrate) .outputOptions(`-ar ${audioSampleRate}`) .toFormat(outputFormat) .output(dest) .once("start", cmd => this.ffmpegLog(cmd)) .once("error", (err) => { fs.removeSync(dest); // 无音频返回false if ( err.message.indexOf("no streams") != -1 || err.message.indexOf("not contain") != -1 ) return resolve(false); reject(err); }) .once("end", () => resolve(true)) .run(); }); }, /** * 音频裁剪 * * @param {string} source 视频来源 * @param {string} dest 输出目标 * @param {Object} [options] - 输出选项 * @param {number} [options.seekStart] - 裁剪开始时间点 * @param {number} [options.seekEnd] - 裁剪结束时间点 */ async clipAudio(source, dest, options = {}) { assert(_.isObject(options), "options must be Object"); const { seekStart = 0, seekEnd } = options; assert(_.isString(source), "source must be an url or path"); assert(_.isString(dest), "dest must be an path"); const acodeer = ffmpeg(); acodeer.addInput(source); _.isFinite(seekStart) && acodeer.addInputOption("-ss", this.millisecondsToHmss(seekStart)); _.isFinite(seekEnd) && seekEnd > (seekStart || 0) && acodeer.addInputOption("-to", this.millisecondsToHmss(seekEnd)); return new Promise((resolve, reject) => { acodeer .audioCodec("copy") .output(dest) .once("start", cmd => this.ffmpegLog(cmd)) .once("error", reject) .once("end", resolve) .run(); }); }, /** * RGB色值转十六进制字符串 * * @param {number} r - 红色 * @param {number} g - 绿色 * @param {number} b - 蓝色 * @returns {string} - 十六进制字符串 */ rgbToHex(r, g, b) { return ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); }, /** * 十六进制字符串转RGB色值 * * @param {string} hex - 十六进制字符串 * @returns {Array} RGB色值数组 */ hexToRgb(hex) { const value = parseInt(hex.replace(/^#/, ""), 16); return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; }, /** * MD5计算 * * @param {string} value - 值 * @returns {string} - MD5值 */ md5(value) { return crypto.createHash("md5").update(value).digest("hex"); }, /** * CRC32计算 * * @param {string|Buffer} value - 值 * @returns {number} - CRC32值 */ crc32(value) { return _.isBuffer(value) ? CRC32.buf(value) : CRC32.str(value); }, /** * 下载资源 * * @param {string} url - 资源URL * @param {string|writable} dest - 写入目标 * @param {Object} [options] - 下载选项 * @param {Function} [options.onProgress] - 下载进度回调 * @param {string[]|RegExp[]} [options.mimesLimit] - 限制MIME类型列表 * @param {string[]} [options.retryFetchs=0] - 重试次数 * @param {string[]} [options.retryDelay=500] - 重试延迟 */ async download(url, dest, options = {}) { const { onProgress, mimesLimit, retryFetchs = 0, retryDelay = 500, _retryCount = 0, ..._options } = options; assert(this.isURL(url), `url ${url} is invalid`); url = url.replace("localhost", "127.0.0.1"); let writeStream; if (_.isString(dest)) writeStream = fs.createWriteStream(dest); else if (this.isWriteStream(dest)) writeStream = dest; else throw new Error("Download dest is invalid"); const { size } = await this.checkRemoteResource(url, mimesLimit); const response = await got.stream(url, _options); return await new Promise((resolve, reject) => { if (onProgress) { let writtenSize = 0; response.on("data", (chunk) => { writtenSize += chunk.length; onProgress(Math.floor(writtenSize / size * 100)); // 更新进度 }); } response.on("end", resolve); response.on("error", err => { if (retryFetchs > _retryCount) { setTimeout(() => { resolve(this.download(url, dest, { ...options, _retryCount: _retryCount + 1 })) }, retryDelay); } else reject(new Error(`Download resource ${url} failed: ${err.message}`)); }); response.pipe(writeStream); }); }, /** * FFmpeg日志汇总输出 * * @param {string} log - 日志内容 */ ffmpegLog(log) { if(!globalConfig.ffmpegDebug) return; logger._debug(log); } }