diff --git a/src/electron/backend/consts.ts b/src/electron/backend/consts.ts index 801f0ad..95e26e7 100644 --- a/src/electron/backend/consts.ts +++ b/src/electron/backend/consts.ts @@ -1,3 +1,3 @@ export const RECORD_FRAME_RATE = 0.5; export const ENCODING_FRAME_RATE = 30; -export const ENCODING_FRAME_INTERVAL = 1 / ENCODING_FRAME_RATE; \ No newline at end of file +export const ENCODING_FRAME_INTERVAL = 1 / ENCODING_FRAME_RATE; diff --git a/src/electron/backend/encoding.ts b/src/electron/backend/encoding.ts index 01976f8..22fdbb4 100644 --- a/src/electron/backend/encoding.ts +++ b/src/electron/backend/encoding.ts @@ -4,12 +4,8 @@ import fs from "fs"; import path, { join } from "path"; import type { EncodingTask, Frame } from "./schema"; import sizeOf from "image-size"; -import { - getEncodingTempDir, - getEncodeCommand, - getRecordingsDir, - getScreenshotsDir -} from "../utils/backend.js"; +import { getEncodeCommand } from "../utils/video/index.js"; +import { getRecordingsDir, getEncodingTempDir, getScreenshotsDir } from "../utils/fs/index.js"; import cache from "memory-cache"; import { ENCODING_FRAME_INTERVAL, RECORD_FRAME_RATE as FRAME_RATE } from "./consts.js"; diff --git a/src/electron/backend/init.ts b/src/electron/backend/init.ts index c9b21d0..19242de 100644 --- a/src/electron/backend/init.ts +++ b/src/electron/backend/init.ts @@ -2,7 +2,7 @@ import * as path from "path"; import { Database } from "better-sqlite3"; import DB from "better-sqlite3"; import { __dirname } from "../dirname.js"; -import { getDatabaseDir } from "../utils/backend.js"; +import { getDatabaseDir } from "../utils/fs/index.js"; import { migrate } from "./migrate/index.js"; function getLibSimpleExtensionPath() { diff --git a/src/electron/backend/screenshot.ts b/src/electron/backend/screenshot.ts index e95fe7f..78a8537 100644 --- a/src/electron/backend/screenshot.ts +++ b/src/electron/backend/screenshot.ts @@ -1,5 +1,5 @@ import screenshot from "screenshot-desktop"; -import { getScreenshotsDir } from "../utils/backend.js"; +import { getScreenshotsDir } from "../utils/fs/index.js"; import { join } from "path"; import { Database } from "better-sqlite3"; import SqlString from "sqlstring"; @@ -11,7 +11,7 @@ export function startScreenshotLoop(db: Database) { const filename = `${timestamp}.png`; const screenshotPath = join(screenshotDir, filename); screenshot({ filename: screenshotPath, format: "png" }) - .then((absolutePath) => { + .then(() => { const SQL = SqlString.format( "INSERT INTO frame (imgFilename, createdAt) VALUES (?, ?)", [filename, new Date().getTime() / 1000] diff --git a/src/electron/createWindow.ts b/src/electron/createWindow.ts index 383cbb8..4ec7944 100644 --- a/src/electron/createWindow.ts +++ b/src/electron/createWindow.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, screen } from "electron"; import { join } from "path"; import { __dirname } from "./dirname.js"; import windowStateManager from "electron-window-state"; -import { hideDock, showDock } from "./utils/electron.js"; +import { hideDock, showDock } from "./utils/platform/index.js"; function loadURL(window: BrowserWindow, path = "", vitePort: string) { const dev = !app.isPackaged; @@ -21,7 +21,7 @@ function loadURL(window: BrowserWindow, path = "", vitePort: string) { } } -export function createSettingsWindow(vitePort: string, closeCallBack: Function) { +export function createSettingsWindow(vitePort: string, closeCallBack: () => void) { const windowState = windowStateManager({ defaultWidth: 650, defaultHeight: 550 @@ -63,6 +63,7 @@ export function createSettingsWindow(vitePort: string, closeCallBack: Function) window.hide(); windowState.saveState(window); e.preventDefault(); + closeCallBack(); }); window.once("close", () => { window.hide(); @@ -72,7 +73,7 @@ export function createSettingsWindow(vitePort: string, closeCallBack: Function) return window; } -export function createMainWindow(vitePort: string, closeCallBack: Function) { +export function createMainWindow(vitePort: string, closeCallBack: () => void) { const display = screen.getPrimaryDisplay(); const { width, height } = display.bounds; const windowState = windowStateManager({ @@ -104,6 +105,7 @@ export function createMainWindow(vitePort: string, closeCallBack: Function) { window.on("close", () => { windowState.saveState(window); + closeCallBack(); }); window.once("close", () => { closeCallBack(); diff --git a/src/electron/index.ts b/src/electron/index.ts index 0397f5a..57b9d37 100644 --- a/src/electron/index.ts +++ b/src/electron/index.ts @@ -16,7 +16,7 @@ import { initDatabase } from "./backend/init.js"; import { Database } from "better-sqlite3"; import { startScreenshotLoop } from "./backend/screenshot.js"; import { __dirname } from "./dirname.js"; -import { hideDock } from "./utils/electron.js"; +import { hideDock } from "./utils/platform/index.js"; import { checkFramesForEncoding, deleteUnnecessaryScreenshots, @@ -24,7 +24,7 @@ import { } from "./backend/encoding.js"; import honoApp from "./server/index.js"; import { serve } from "@hono/node-server"; -import { findAvailablePort } from "./utils/server.js"; +import { findAvailablePort } from "./utils/network/index.js"; import cache from "memory-cache"; import { generate as generateAPIKey } from "@alikia/random-key"; diff --git a/src/electron/server/index.ts b/src/electron/server/index.ts index 5a20454..092c7dc 100644 --- a/src/electron/server/index.ts +++ b/src/electron/server/index.ts @@ -5,13 +5,12 @@ import fs from "fs"; import { Database } from "better-sqlite3"; import type { Frame } from "../backend/schema"; import { - extractFramesFromVideo, getDecodingTempDir, getRecordingsDir, getScreenshotsDir, - immediatelyExtractFrameFromVideo, waitForFileExists -} from "../utils/backend.js"; +} from "../utils/fs/index.js"; +import { immediatelyExtractFrameFromVideo } from "../utils/video/index.js"; import { existsSync } from "fs"; const app = new Hono(); @@ -80,30 +79,23 @@ app.get("/frame/:id", async (c) => { .get(id) as Frame | undefined; if (!frame) return c.json({ error: "Frame not found" }, 404); - + const decodingTempDir = getDecodingTempDir(); const screenshotsDir = getScreenshotsDir(); const videoFilename = frame.videoPath; const frameIndex = frame.videoFrameIndex; const imageFilename = frame.imgFilename; const bareVideoFilename = videoFilename?.replace(".mp4", "") || null; - const decodedImageBMP = frameIndex - ? `${bareVideoFilename}_${(frameIndex).toString().padStart(4, "0")}.bmp` - : null; - const decodedImagePNG = frameIndex - ? `${bareVideoFilename}_${(frameIndex).toString().padStart(4, "0")}.png` + const decodedImage = frameIndex + ? `${bareVideoFilename}_${frameIndex.toString().padStart(4, "0")}.bmp` : null; let returnImagePath = ""; let needToBeDecoded = videoFilename !== null && frameIndex !== null && !frame.imgFilename; - if (decodedImagePNG && fs.existsSync(join(getDecodingTempDir(), decodedImagePNG))) { + if (decodedImage && fs.existsSync(join(getDecodingTempDir(), decodedImage))) { needToBeDecoded = false; - returnImagePath = join(decodingTempDir, decodedImagePNG); - } else if (decodedImageBMP && fs.existsSync(join(getDecodingTempDir(), decodedImageBMP))) { - needToBeDecoded = false; - returnImagePath = join(decodingTempDir, decodedImageBMP); - } - else if (imageFilename && fs.existsSync(join(screenshotsDir, imageFilename))) { + returnImagePath = join(decodingTempDir, decodedImage); + } else if (imageFilename && fs.existsSync(join(screenshotsDir, imageFilename))) { returnImagePath = join(screenshotsDir, imageFilename); } @@ -113,15 +105,15 @@ app.get("/frame/:id", async (c) => { if (!videoExists) { return c.json({ error: "Video not found" }, { status: 404 }); } - - // Decode requesting frame immediately, then decode the whole video chunk - // This allows the user to get an immediate response, - // and we get prepared for the next few seconds of scrolling (timeline). - const decodedFilename = immediatelyExtractFrameFromVideo(videoFilename!, frameIndex!, decodingTempDir); + + const decodedFilename = immediatelyExtractFrameFromVideo( + videoFilename!, + frameIndex!, + decodingTempDir + ); const decodedPath = join(decodingTempDir, decodedFilename); await waitForFileExists(decodedPath); - extractFramesFromVideo(videoFilename!, null, null, decodingTempDir); if (existsSync(decodedPath)) { const imageBuffer = fs.readFileSync(decodedPath); @@ -136,10 +128,11 @@ app.get("/frame/:id", async (c) => { } } else { const imageBuffer = fs.readFileSync(returnImagePath); + const imageMimeType = imageFilename?.endsWith(".png") ? "image/png" : "image/jpeg"; return new Response(imageBuffer, { status: 200, headers: { - "Content-Type": "image/png" + "Content-Type": imageMimeType } }); } diff --git a/src/electron/utils/backend.ts b/src/electron/utils/backend.ts deleted file mode 100644 index afffa85..0000000 --- a/src/electron/utils/backend.ts +++ /dev/null @@ -1,281 +0,0 @@ -import path, { join } from "path"; -import os from "os"; -import fs from "fs"; -import { __dirname } from "../dirname.js"; -import { execSync, spawn } from "child_process"; -import cache from "memory-cache"; -import { exec } from "child_process"; -import { ENCODING_FRAME_RATE } from "../backend/consts.js"; - -const DECODE_CONCURRENCY = 1; - -function getTasksPerforming() { - return (cache.get("backend:extractFramesTasksPerforming") as string[]) || []; -} - -export function getUserDataDir() { - switch (process.platform) { - case "win32": - return path.join(process.env.APPDATA!, "OpenRewind", "Record Data"); - case "darwin": - return path.join( - os.homedir(), - "Library", - "Application Support", - "OpenRewind", - "Record Data" - ); - case "linux": - return path.join(os.homedir(), ".config", "OpenRewind", "Record Data"); - default: - throw new Error("Unsupported platform"); - } -} - -export function createDataDir() { - const dataDir = getUserDataDir(); - if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); - } - return dataDir; -} - -export function createTempDir() { - const tempDir = path.join(getUserDataDir(), "temp"); - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - return tempDir; -} - -export function getDatabaseDir() { - const dataDir = createDataDir(); - return path.join(dataDir, "main.db"); -} - -export function getScreenshotsDir() { - const tempDir = createTempDir(); - const screenshotsDir = path.join(tempDir, "screenshots"); - if (!fs.existsSync(screenshotsDir)) { - fs.mkdirSync(screenshotsDir, { recursive: true }); - } - return screenshotsDir; -} - -export function getRecordingsDir() { - const dataDir = createDataDir(); - const recordingsDir = path.join(dataDir, "recordings"); - if (!fs.existsSync(recordingsDir)) { - fs.mkdirSync(recordingsDir, { recursive: true }); - } - return path.join(dataDir, "recordings"); -} - -export function getEncodingTempDir() { - const tempDir = createTempDir(); - const encodingTempDir = path.join(tempDir, "encoding"); - if (!fs.existsSync(encodingTempDir)) { - fs.mkdirSync(encodingTempDir, { recursive: true }); - } - return encodingTempDir; -} - -export function getDecodingTempDir() { - const tempDir = createTempDir(); - const decodingTempDir = path.join(tempDir, "decoding"); - if (!fs.existsSync(decodingTempDir)) { - fs.mkdirSync(decodingTempDir, { recursive: true }); - } - return decodingTempDir; -} - -export function getFFmpegPath() { - switch (process.platform) { - case "win32": - return path.join(__dirname, "bin", process.platform, "ffmpeg.exe"); - case "darwin": - return path.join(__dirname, "bin", process.platform, "ffmpeg"); - case "linux": - return path.join(__dirname, "bin", process.platform, "ffmpeg"); - default: - throw new Error("Unsupported platform"); - } -} - -function getBestCodec() { - const cachedCodec = cache.get("backend:bestCodec"); - if (cachedCodec) { - return cachedCodec; - } - const codecs = execSync(`${getFFmpegPath()} -codecs`).toString("utf-8"); - let codec = ""; - if (codecs.includes("h264_videotoolbox")) { - codec = "h264_videotoolbox"; - } else { - codec = "libx264"; - } - cache.put("backend:bestCodec", codec); - return codec; -} - -export function getEncodeCommand(metaFilePath: string, videoPath: string) { - const codec = getBestCodec(); - return `${getFFmpegPath()} -f concat -safe 0 -i "${metaFilePath}" -c:v ${codec} -r ${ENCODING_FRAME_RATE} -y -threads 1 "${videoPath}"`; -} - -/** - * Extracts frames from a video file using FFmpeg - * - * @async - * @param {string} videoFilename - The name of the video file to extract frames from - * @param {number|null} startIndex - The starting frame index (inclusive). If null, starts from first frame - * @param {number|null} endIndex - The ending frame index (inclusive). If null, goes to last frame - * @param {string|null} outputPath - The output path for the extracted frames - */ -export async function extractFramesFromVideo( - videoFilename: string, - startIndex: number | null, - endIndex: number | null, - outputPath: string = ".", - format: "png" | "bmp" = "png" -) { - const tasksPerforming = getTasksPerforming(); - if (tasksPerforming.length >= DECODE_CONCURRENCY) { - console.log(`Reached concurrency limit (${DECODE_CONCURRENCY}), skipping extraction`); - return; - } - - const taskId = `${videoFilename}-${startIndex}-${endIndex}`; - cache.put("backend:extractFramesTasksPerforming", [...tasksPerforming, taskId]); - const fullVideoPath = join(getRecordingsDir(), videoFilename); - - const beginTimeArg = - startIndex !== null ? `-ss ${formatTime(startIndex / ENCODING_FRAME_RATE)}` : ""; - const endTimeArg = endIndex !== null ? `-to ${formatTime(endIndex / ENCODING_FRAME_RATE)}` : ""; - - let videoFilter = ""; - if (startIndex !== null && endIndex !== null) { - videoFilter = `select='between(n\\,${startIndex}\\,${endIndex})'`; - } else if (startIndex !== null) { - videoFilter = `select='gte(n\\,${startIndex})'`; - } else if (endIndex !== null) { - videoFilter = `select='lte(n\\,${endIndex})'`; - } - - const bareVideoFilename = videoFilename.split(".").slice(0, -1).join("."); - const outputPathArg = join(outputPath, `${bareVideoFilename}_%04d.${format}`); - - const command = [ - getFFmpegPath(), - beginTimeArg, - endTimeArg, - `-i "${fullVideoPath}"`, - videoFilter ? `-vf ${videoFilter}` : "", - `-start_number ${startIndex || 0}`, - `"${outputPathArg}"` - ] - .filter((arg) => arg !== "") - .join(" "); - - exec(command, (error, _stdout, _stderr) => { - if (error) { - console.error(`FFmpeg error: ${error.message}`); - } - const tasksPerforming = getTasksPerforming(); - cache.put( - "backend:extractFramesTasksPerforming", - tasksPerforming.filter((id) => id !== taskId) - ); - }); -} - -export function immediatelyExtractFrameFromVideo( - videoFilename: string, - frameIndex: number, - outputPath = "." -) { - const bareVideoFilename = videoFilename.split(".").slice(0, -1).join("."); - const fullVideoPath = join(getRecordingsDir(), videoFilename); - const outputFilename = `${bareVideoFilename}_${frameIndex.toString().padStart(4, "0")}.bmp`; - const outputPathArg = join(outputPath, outputFilename); - const args = [ - "-i", - `${fullVideoPath}`, - "-ss", - `${formatTime(frameIndex / ENCODING_FRAME_RATE)}`, - "-vframes", - "1", - `${outputPathArg}` - ]; - const ffmpeg = spawn("ffmpeg", args); - ffmpeg.stdout.on("data", (data) => { - console.log(data.toString()); - }); - - ffmpeg.stderr.on("data", (data) => { - console.log(data.toString()); - }); - ffmpeg.on("exit", (code) => { - if (code !== 0) { - console.error("Error extracting frame:", code); - } - }); - return outputFilename; -} - -function formatTime(seconds: number): string { - // Calculate hours, minutes, seconds, and milliseconds - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - const milliseconds = Math.round((seconds % 1) * 1000); - - // Format the output with leading zeros - const formattedTime = - [ - String(hours).padStart(2, "0"), - String(minutes).padStart(2, "0"), - String(secs).padStart(2, "0") - ].join(":") + - "." + - String(milliseconds).padStart(3, "0"); - - return formattedTime; -} - -export async function waitForFileExists(filePath: string, timeout: number = 10000): Promise { - return new Promise((resolve, reject) => { - fs.access(filePath, fs.constants.F_OK, (err) => { - if (!err) { - resolve(); - return; - } - - const dir = path.dirname(filePath); - const filename = path.basename(filePath); - - const watcher = fs.watch(dir, (eventType, watchedFilename) => { - if (eventType === "rename" && watchedFilename === filename) { - fs.access(filePath, fs.constants.F_OK, (err) => { - if (!err) { - clearTimeout(timeoutId); - watcher.close(); - resolve(); - } - }); - } - }); - - watcher.on("error", (err) => { - clearTimeout(timeoutId); - watcher.close(); - reject(err); - }); - - const timeoutId = setTimeout(() => { - watcher.close(); - reject(new Error(`Timeout: File ${filePath} did not exist within ${timeout}ms`)); - }, timeout); - }); - }); -} diff --git a/src/electron/utils/electron.ts b/src/electron/utils/electron.ts deleted file mode 100644 index fd89751..0000000 --- a/src/electron/utils/electron.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { app } from "electron"; - -export function hideDock() { - if (process.platform === "darwin") { - // Hide the dock icon on macOS - app.dock.hide(); - } -} - -export function showDock() { - if (process.platform === "darwin") { - // Show the dock icon on macOS - app.dock.show(); - } -} diff --git a/src/electron/utils/fs/index.ts b/src/electron/utils/fs/index.ts new file mode 100644 index 0000000..aa25fa5 --- /dev/null +++ b/src/electron/utils/fs/index.ts @@ -0,0 +1,97 @@ +import path from "path"; +import fs from "fs"; +import { getUserDataDir } from "../platform/index.js"; + +export function createDataDir() { + const dataDir = getUserDataDir(); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + return dataDir; +} + +export function createTempDir() { + const tempDir = path.join(getUserDataDir(), "temp"); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + return tempDir; +} + +export function getDatabaseDir() { + const dataDir = createDataDir(); + return path.join(dataDir, "main.db"); +} + +export function getScreenshotsDir() { + const tempDir = createTempDir(); + const screenshotsDir = path.join(tempDir, "screenshots"); + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }); + } + return screenshotsDir; +} + +export function getRecordingsDir() { + const dataDir = createDataDir(); + const recordingsDir = path.join(dataDir, "recordings"); + if (!fs.existsSync(recordingsDir)) { + fs.mkdirSync(recordingsDir, { recursive: true }); + } + return path.join(dataDir, "recordings"); +} + +export function getEncodingTempDir() { + const tempDir = createTempDir(); + const encodingTempDir = path.join(tempDir, "encoding"); + if (!fs.existsSync(encodingTempDir)) { + fs.mkdirSync(encodingTempDir, { recursive: true }); + } + return encodingTempDir; +} + +export function getDecodingTempDir() { + const tempDir = createTempDir(); + const decodingTempDir = path.join(tempDir, "decoding"); + if (!fs.existsSync(decodingTempDir)) { + fs.mkdirSync(decodingTempDir, { recursive: true }); + } + return decodingTempDir; +} + +export async function waitForFileExists(filePath: string, timeout: number = 10000): Promise { + return new Promise((resolve, reject) => { + fs.access(filePath, fs.constants.F_OK, (err) => { + if (!err) { + resolve(); + return; + } + + const dir = path.dirname(filePath); + const filename = path.basename(filePath); + + const watcher = fs.watch(dir, (eventType, watchedFilename) => { + if (eventType === "rename" && watchedFilename === filename) { + fs.access(filePath, fs.constants.F_OK, (err) => { + if (!err) { + clearTimeout(timeoutId); + watcher.close(); + resolve(); + } + }); + } + }); + + watcher.on("error", (err) => { + clearTimeout(timeoutId); + watcher.close(); + reject(err); + }); + + const timeoutId = setTimeout(() => { + watcher.close(); + reject(new Error(`Timeout: File ${filePath} did not exist within ${timeout}ms`)); + }, timeout); + }); + }); +} diff --git a/src/electron/utils/index.ts b/src/electron/utils/index.ts new file mode 100644 index 0000000..75619e4 --- /dev/null +++ b/src/electron/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./fs/index.js"; +export * from "./platform/index.js"; +export * from "./video/index.js"; +export * from "./network/index.js"; diff --git a/src/electron/utils/server.ts b/src/electron/utils/network/index.ts similarity index 100% rename from src/electron/utils/server.ts rename to src/electron/utils/network/index.ts diff --git a/src/electron/utils/platform/index.ts b/src/electron/utils/platform/index.ts new file mode 100644 index 0000000..b862dc7 --- /dev/null +++ b/src/electron/utils/platform/index.ts @@ -0,0 +1,50 @@ +import path from "path"; +import os from "os"; +import { app } from "electron"; +import { __dirname } from "../../dirname.js"; + +export function getUserDataDir() { + switch (process.platform) { + case "win32": + return path.join(process.env.APPDATA!, "OpenRewind", "Record Data"); + case "darwin": + return path.join( + os.homedir(), + "Library", + "Application Support", + "OpenRewind", + "Record Data" + ); + case "linux": + return path.join(os.homedir(), ".config", "OpenRewind", "Record Data"); + default: + throw new Error("Unsupported platform"); + } +} + +export function hideDock() { + if (process.platform === "darwin") { + // Hide the dock icon on macOS + app.dock.hide(); + } +} + +export function showDock() { + if (process.platform === "darwin") { + // Show the dock icon on macOS + app.dock.show(); + } +} + +export function getFFmpegPath() { + switch (process.platform) { + case "win32": + return path.join(__dirname, "bin", process.platform, "ffmpeg.exe"); + case "darwin": + return path.join(__dirname, "bin", process.platform, "ffmpeg"); + case "linux": + return path.join(__dirname, "bin", process.platform, "ffmpeg"); + default: + throw new Error("Unsupported platform"); + } +} diff --git a/src/electron/utils/video/index.ts b/src/electron/utils/video/index.ts new file mode 100644 index 0000000..a48d9a8 --- /dev/null +++ b/src/electron/utils/video/index.ts @@ -0,0 +1,81 @@ +import { join } from "path"; +import { spawn, execSync } from "child_process"; +import { getRecordingsDir } from "../fs/index.js"; +import { getFFmpegPath } from "../platform/index.js"; +import { ENCODING_FRAME_RATE } from "../../backend/consts.js"; +import cache from "memory-cache"; + +function getBestCodec() { + const cachedCodec = cache.get("backend:bestCodec"); + if (cachedCodec) { + return cachedCodec; + } + const codecs = execSync(`${getFFmpegPath()} -codecs`).toString("utf-8"); + let codec = ""; + if (codecs.includes("h264_videotoolbox")) { + codec = "h264_videotoolbox"; + } else { + codec = "libx264"; + } + cache.put("backend:bestCodec", codec); + return codec; +} + +export function getEncodeCommand(metaFilePath: string, videoPath: string) { + const codec = getBestCodec(); + return `${getFFmpegPath()} -f concat -safe 0 -i "${metaFilePath}" -c:v ${codec} -r ${ENCODING_FRAME_RATE} -y -threads 1 "${videoPath}"`; +} + +export function immediatelyExtractFrameFromVideo( + videoFilename: string, + frameIndex: number, + outputPath = "." +) { + const bareVideoFilename = videoFilename.split(".").slice(0, -1).join("."); + const fullVideoPath = join(getRecordingsDir(), videoFilename); + const outputFilename = `${bareVideoFilename}_${frameIndex.toString().padStart(4, "0")}.bmp`; + const outputPathArg = join(outputPath, outputFilename); + const args = [ + "-ss", + `${formatTime(frameIndex / ENCODING_FRAME_RATE)}`, + "-i", + `${fullVideoPath}`, + "-vframes", + "1", + `${outputPathArg}` + ]; + const ffmpeg = spawn("ffmpeg", args); + ffmpeg.stdout.on("data", (data) => { + console.log(data.toString()); + }); + + ffmpeg.stderr.on("data", (data) => { + console.log(data.toString()); + }); + ffmpeg.on("exit", (code) => { + if (code !== 0) { + console.error("Error extracting frame:", code); + } + }); + return outputFilename; +} + +function formatTime(seconds: number): string { + // Calculate hours, minutes, seconds, and milliseconds + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + const milliseconds = Math.round((seconds % 1) * 1000); + + // Format the output with leading zeros + const formattedTime = + [ + String(hours).padStart(2, "0"), + String(minutes).padStart(2, "0"), + String(secs).padStart(2, "0") + ].join(":") + + "." + + String(milliseconds).padStart(3, "0"); + + return formattedTime; +}