UNPKG

screencapturekit

Version:

A nodejs wrapper over a swift CLI program which is a wrapper over ScreenCaptureKit module with HDR and microphone support

415 lines (412 loc) 14.5 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { ScreenCaptureKit: () => ScreenCaptureKit, ScreenCaptureKitError: () => ScreenCaptureKitError, audioDevices: () => audioDevices, default: () => index_default, microphoneDevices: () => microphoneDevices, screens: () => screens, supportsHDRCapture: () => supportsHDRCapture, videoCodecs: () => videoCodecs }); module.exports = __toCommonJS(index_exports); var import_node_os = __toESM(require("os"), 1); var import_node_path = __toESM(require("path"), 1); var import_node_fs = __toESM(require("fs"), 1); var import_uuid = require("uuid"); var macosVersion = __toESM(require("macos-version"), 1); var import_execa = require("execa"); // src/utils/packagePaths.ts var import_path = __toESM(require("path"), 1); var import_url = require("url"); var import_meta = {}; var getPackageRoot = () => { if (process.env.NODE_ENV === "test") { return import_path.default.join(__dirname, "..", "..", "dist"); } try { const app = require("electron").app; const packageMainPath = require.resolve("screencapturekit"); if (typeof process.resourcesPath === "string" && app?.isPackaged) { const finalPath2 = import_path.default.join(process.resourcesPath); return finalPath2; } const finalPath = import_path.default.dirname(packageMainPath); return finalPath; } catch (e) { const __filename = (0, import_url.fileURLToPath)(import_meta.url); const finalPath = import_path.default.join(import_path.default.dirname(__filename)); console.log("finalPath : ESM", finalPath); return finalPath; } }; var resolvePackagePath = (...segments) => import_path.default.join(getPackageRoot(), ...segments); // src/index.ts var BIN = resolvePackagePath("./screencapturekit"); var getRandomId = () => Math.random().toString(36).slice(2, 15); var supportsHevcHardwareEncoding = (() => { const cpuModel = import_node_os.default.cpus()[0].model; if (cpuModel.startsWith("Apple ")) { return true; } const result = /Intel.*Core.*i\d+-(\d)/.exec(cpuModel); return result && Number.parseInt(result[1], 10) >= 6; })(); var supportsHDR = (() => { return macosVersion.isMacOSVersionGreaterThanOrEqualTo("13.0"); })(); var supportsDirectRecordingAPI = (() => { return macosVersion.isMacOSVersionGreaterThanOrEqualTo("15.0"); })(); var supportsMicrophoneCapture = (() => { return macosVersion.isMacOSVersionGreaterThanOrEqualTo("15.0"); })(); var ScreenCaptureKit = class { /** Path to the output video file. */ videoPath = null; /** The ongoing recording process. */ recorder; /** Unique identifier of the recording process. */ processId = null; /** Options used for recording */ currentOptions; /** Path to the final processed video file */ processedVideoPath = null; /** * Creates a new instance of ScreenCaptureKit. * Checks that the macOS version is compatible (10.13+). * @throws {Error} If the macOS version is not supported. */ constructor() { macosVersion.assertMacOSVersionGreaterThanOrEqualTo("10.13"); } /** * Checks that recording has been started. * @throws {Error} If recording has not been started. * @private */ throwIfNotStarted() { if (this.recorder === void 0) { throw new Error("Call `.startRecording()` first"); } } /** * Starts screen recording. * @param {Partial<RecordingOptions>} options - Recording options. * @param {number} [options.fps=30] - Frames per second. * @param {CropArea} [options.cropArea] - Area of the screen to capture. * @param {boolean} [options.showCursor=true] - Show the cursor. * @param {boolean} [options.highlightClicks=false] - Highlight mouse clicks. * @param {number} [options.screenId=0] - Screen ID to capture. * @param {string} [options.audioDeviceId] - System audio device ID. * @param {string} [options.microphoneDeviceId] - Microphone device ID. * @param {string} [options.videoCodec="h264"] - Video codec to use. * @param {boolean} [options.enableHDR=false] - Enable HDR recording. * @param {boolean} [options.recordToFile=false] - Use the direct recording API. * @returns {Promise<void>} A promise that resolves when recording starts. * @throws {Error} If recording is already in progress or if the options are invalid. */ async startRecording({ fps = 30, cropArea = void 0, showCursor = true, highlightClicks = false, screenId = 0, audioDeviceId = void 0, microphoneDeviceId = void 0, videoCodec = "h264", enableHDR = false, recordToFile = false, audioOnly = false } = {}) { this.processId = getRandomId(); this.currentOptions = { fps, cropArea, showCursor, highlightClicks, screenId, audioDeviceId, microphoneDeviceId, videoCodec, enableHDR, recordToFile, audioOnly }; return new Promise((resolve, reject) => { if (this.recorder !== void 0) { reject(new Error("Call `.stopRecording()` first")); return; } this.videoPath = createTempFile({ extension: "mp4" }); console.log(this.videoPath); const recorderOptions = { destination: fileUrlFromPath(this.videoPath), framesPerSecond: fps, showCursor, highlightClicks, screenId, audioDeviceId }; if (highlightClicks === true) { showCursor = true; } if (typeof cropArea === "object" && (typeof cropArea.x !== "number" || typeof cropArea.y !== "number" || typeof cropArea.width !== "number" || typeof cropArea.height !== "number")) { reject(new Error("Invalid `cropArea` option object")); return; } if (videoCodec) { if (!videoCodecs.has(videoCodec)) { throw new Error(`Unsupported video codec specified: ${videoCodec}`); } recorderOptions.videoCodec = videoCodecs.get(videoCodec); } if (enableHDR) { if (!supportsHDR) { console.warn( "HDR requested but not supported on this macOS version. Falling back to SDR." ); } else { recorderOptions.enableHDR = true; } } if (microphoneDeviceId) { if (!supportsMicrophoneCapture) { console.warn( "Microphone capture requested but requires macOS 15.0+. This feature will be ignored." ); } else { recorderOptions.microphoneDeviceId = microphoneDeviceId; } } if (recordToFile) { if (!supportsDirectRecordingAPI) { console.warn( "Direct recording API requested but requires macOS 15.0+. Falling back to manual recording." ); } else { recorderOptions.useDirectRecordingAPI = true; } } if (cropArea) { recorderOptions.cropRect = [ [cropArea.x, cropArea.y], [cropArea.width, cropArea.height] ]; } const timeout = setTimeout(resolve, 1e3); this.recorder = (0, import_execa.execa)(BIN, ["record", JSON.stringify(recorderOptions)]); this.recorder?.catch((error) => { clearTimeout(timeout); delete this.recorder; reject(error); }); this.recorder?.stdout?.setEncoding("utf8"); this.recorder?.stdout?.on("data", (data) => { console.log("From swift executable: ", data); }); }); } /** * Stops the ongoing recording and processes the video to merge audio tracks if needed. * @returns {Promise<string|null>} A promise that resolves with the path to the processed video file. * @throws {Error} If recording has not been started. */ async stopRecording() { this.throwIfNotStarted(); console.log("Arr\xEAt de l'enregistrement"); this.recorder?.kill(); await this.recorder; console.log("Enregistrement arr\xEAt\xE9"); this.recorder = void 0; if (!this.videoPath) { return null; } await new Promise((resolve) => setTimeout(resolve, 1e3)); let currentFile = this.videoPath; try { const stats = import_node_fs.default.statSync(currentFile); if (stats.size === 0) { console.error("Le fichier d'enregistrement est vide"); return null; } } catch (error) { console.error("Erreur lors de la v\xE9rification du fichier d'enregistrement:", error); return null; } const hasMultipleAudioTracks = !!(this.currentOptions?.audioDeviceId && this.currentOptions?.microphoneDeviceId); if (hasMultipleAudioTracks) { try { console.log("Fusion des pistes audio avec ffmpeg"); this.processedVideoPath = createTempFile({ extension: "mp4" }); const { stdout: probeOutput } = await (0, import_execa.execa)("ffprobe", [ "-v", "error", "-show_entries", "stream=index,codec_type", "-of", "json", currentFile ]); const probeResult = JSON.parse(probeOutput); const streams = probeResult.streams || []; const audioStreams = streams.filter((stream) => stream.codec_type === "audio").map((stream) => stream.index); const videoStream = streams.find((stream) => stream.codec_type === "video")?.index; if (audioStreams.length < 2 || videoStream === void 0) { console.log("Pas assez de pistes audio pour fusionner ou pas de piste vid\xE9o"); } else { const systemAudioIndex = audioStreams[0]; const microphoneIndex = audioStreams[1]; const filterComplex = `[0:${systemAudioIndex}]volume=1[a1];[0:${microphoneIndex}]volume=3[a2];[a1][a2]amerge=inputs=2[aout]`; await (0, import_execa.execa)("ffmpeg", [ "-i", currentFile, "-filter_complex", filterComplex, "-map", "[aout]", "-map", `0:${videoStream}`, "-c:v", "copy", "-c:a", "aac", "-b:a", "256k", "-ac", "2", "-y", this.processedVideoPath ]); currentFile = this.processedVideoPath; } } catch (error) { console.error("Erreur lors de la fusion des pistes audio:", error); } } if (this.currentOptions?.audioOnly) { try { console.log("Conversion en MP3"); const audioPath = createTempFile({ extension: "mp3" }); await (0, import_execa.execa)("ffmpeg", [ "-i", currentFile, "-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-y", audioPath ]); return audioPath; } catch (error) { console.error("Erreur lors de la conversion en MP3:", error); return currentFile; } } return currentFile; } }; function index_default() { return new ScreenCaptureKit(); } function getCodecs() { const codecs = /* @__PURE__ */ new Map([ ["h264", "H264"], ["hevc", "HEVC"], ["proRes422", "Apple ProRes 422"], ["proRes4444", "Apple ProRes 4444"] ]); if (!supportsHevcHardwareEncoding) { codecs.delete("hevc"); } return codecs; } var ScreenCaptureKitError = class extends Error { constructor(message) { super(message); this.name = "ScreenCaptureKitError"; } }; var screens = async () => { const { stderr } = await (0, import_execa.execa)(BIN, ["list", "screens"]); try { return JSON.parse(stderr); } catch { throw new ScreenCaptureKitError(`Failed to retrieve screens: ${stderr}`); } }; var audioDevices = async () => { const { stderr } = await (0, import_execa.execa)(BIN, ["list", "audio-devices"]); try { return JSON.parse(stderr); } catch { throw new ScreenCaptureKitError(`Failed to retrieve audio devices: ${stderr}`); } }; var microphoneDevices = async () => { const { stderr } = await (0, import_execa.execa)(BIN, ["list", "microphone-devices"]); try { return JSON.parse(stderr); } catch { throw new ScreenCaptureKitError(`Failed to retrieve microphones: ${stderr}`); } }; var supportsHDRCapture = supportsHDR; var videoCodecs = getCodecs(); function createTempFile(options = {}) { const tempDir = import_node_os.default.tmpdir(); const randomId = (0, import_uuid.v4)(); const extension = options.extension ? `.${options.extension}` : ""; const tempFilePath = import_node_path.default.join(tempDir, `${randomId}${extension}`); return tempFilePath; } function fileUrlFromPath(filePath) { let pathName = filePath.replace(/\\/g, "/"); if (pathName[0] !== "/") { pathName = "/" + pathName; } pathName = encodeURI(pathName).replace(/#/g, "%23").replace(/\?/g, "%3F"); return `file://${pathName}`; } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { ScreenCaptureKit, ScreenCaptureKitError, audioDevices, microphoneDevices, screens, supportsHDRCapture, videoCodecs }); //# sourceMappingURL=index.cjs.map