ref: utils dir

This commit is contained in:
alikia2x (寒寒) 2025-01-01 02:56:51 +08:00
parent 97dce81297
commit a43a563609
Signed by: alikia2x
GPG Key ID: 56209E0CCD8420C6
14 changed files with 261 additions and 334 deletions

View File

@ -1,3 +1,3 @@
export const RECORD_FRAME_RATE = 0.5; export const RECORD_FRAME_RATE = 0.5;
export const ENCODING_FRAME_RATE = 30; export const ENCODING_FRAME_RATE = 30;
export const ENCODING_FRAME_INTERVAL = 1 / ENCODING_FRAME_RATE; export const ENCODING_FRAME_INTERVAL = 1 / ENCODING_FRAME_RATE;

View File

@ -4,12 +4,8 @@ import fs from "fs";
import path, { join } from "path"; import path, { join } from "path";
import type { EncodingTask, Frame } from "./schema"; import type { EncodingTask, Frame } from "./schema";
import sizeOf from "image-size"; import sizeOf from "image-size";
import { import { getEncodeCommand } from "../utils/video/index.js";
getEncodingTempDir, import { getRecordingsDir, getEncodingTempDir, getScreenshotsDir } from "../utils/fs/index.js";
getEncodeCommand,
getRecordingsDir,
getScreenshotsDir
} from "../utils/backend.js";
import cache from "memory-cache"; import cache from "memory-cache";
import { ENCODING_FRAME_INTERVAL, RECORD_FRAME_RATE as FRAME_RATE } from "./consts.js"; import { ENCODING_FRAME_INTERVAL, RECORD_FRAME_RATE as FRAME_RATE } from "./consts.js";

View File

@ -2,7 +2,7 @@ import * as path from "path";
import { Database } from "better-sqlite3"; import { Database } from "better-sqlite3";
import DB from "better-sqlite3"; import DB from "better-sqlite3";
import { __dirname } from "../dirname.js"; import { __dirname } from "../dirname.js";
import { getDatabaseDir } from "../utils/backend.js"; import { getDatabaseDir } from "../utils/fs/index.js";
import { migrate } from "./migrate/index.js"; import { migrate } from "./migrate/index.js";
function getLibSimpleExtensionPath() { function getLibSimpleExtensionPath() {

View File

@ -1,5 +1,5 @@
import screenshot from "screenshot-desktop"; import screenshot from "screenshot-desktop";
import { getScreenshotsDir } from "../utils/backend.js"; import { getScreenshotsDir } from "../utils/fs/index.js";
import { join } from "path"; import { join } from "path";
import { Database } from "better-sqlite3"; import { Database } from "better-sqlite3";
import SqlString from "sqlstring"; import SqlString from "sqlstring";
@ -11,7 +11,7 @@ export function startScreenshotLoop(db: Database) {
const filename = `${timestamp}.png`; const filename = `${timestamp}.png`;
const screenshotPath = join(screenshotDir, filename); const screenshotPath = join(screenshotDir, filename);
screenshot({ filename: screenshotPath, format: "png" }) screenshot({ filename: screenshotPath, format: "png" })
.then((absolutePath) => { .then(() => {
const SQL = SqlString.format( const SQL = SqlString.format(
"INSERT INTO frame (imgFilename, createdAt) VALUES (?, ?)", "INSERT INTO frame (imgFilename, createdAt) VALUES (?, ?)",
[filename, new Date().getTime() / 1000] [filename, new Date().getTime() / 1000]

View File

@ -2,7 +2,7 @@ import { app, BrowserWindow, screen } from "electron";
import { join } from "path"; import { join } from "path";
import { __dirname } from "./dirname.js"; import { __dirname } from "./dirname.js";
import windowStateManager from "electron-window-state"; 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) { function loadURL(window: BrowserWindow, path = "", vitePort: string) {
const dev = !app.isPackaged; 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({ const windowState = windowStateManager({
defaultWidth: 650, defaultWidth: 650,
defaultHeight: 550 defaultHeight: 550
@ -63,6 +63,7 @@ export function createSettingsWindow(vitePort: string, closeCallBack: Function)
window.hide(); window.hide();
windowState.saveState(window); windowState.saveState(window);
e.preventDefault(); e.preventDefault();
closeCallBack();
}); });
window.once("close", () => { window.once("close", () => {
window.hide(); window.hide();
@ -72,7 +73,7 @@ export function createSettingsWindow(vitePort: string, closeCallBack: Function)
return window; return window;
} }
export function createMainWindow(vitePort: string, closeCallBack: Function) { export function createMainWindow(vitePort: string, closeCallBack: () => void) {
const display = screen.getPrimaryDisplay(); const display = screen.getPrimaryDisplay();
const { width, height } = display.bounds; const { width, height } = display.bounds;
const windowState = windowStateManager({ const windowState = windowStateManager({
@ -104,6 +105,7 @@ export function createMainWindow(vitePort: string, closeCallBack: Function) {
window.on("close", () => { window.on("close", () => {
windowState.saveState(window); windowState.saveState(window);
closeCallBack();
}); });
window.once("close", () => { window.once("close", () => {
closeCallBack(); closeCallBack();

View File

@ -16,7 +16,7 @@ import { initDatabase } from "./backend/init.js";
import { Database } from "better-sqlite3"; import { Database } from "better-sqlite3";
import { startScreenshotLoop } from "./backend/screenshot.js"; import { startScreenshotLoop } from "./backend/screenshot.js";
import { __dirname } from "./dirname.js"; import { __dirname } from "./dirname.js";
import { hideDock } from "./utils/electron.js"; import { hideDock } from "./utils/platform/index.js";
import { import {
checkFramesForEncoding, checkFramesForEncoding,
deleteUnnecessaryScreenshots, deleteUnnecessaryScreenshots,
@ -24,7 +24,7 @@ import {
} from "./backend/encoding.js"; } from "./backend/encoding.js";
import honoApp from "./server/index.js"; import honoApp from "./server/index.js";
import { serve } from "@hono/node-server"; 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 cache from "memory-cache";
import { generate as generateAPIKey } from "@alikia/random-key"; import { generate as generateAPIKey } from "@alikia/random-key";

View File

@ -5,13 +5,12 @@ import fs from "fs";
import { Database } from "better-sqlite3"; import { Database } from "better-sqlite3";
import type { Frame } from "../backend/schema"; import type { Frame } from "../backend/schema";
import { import {
extractFramesFromVideo,
getDecodingTempDir, getDecodingTempDir,
getRecordingsDir, getRecordingsDir,
getScreenshotsDir, getScreenshotsDir,
immediatelyExtractFrameFromVideo,
waitForFileExists waitForFileExists
} from "../utils/backend.js"; } from "../utils/fs/index.js";
import { immediatelyExtractFrameFromVideo } from "../utils/video/index.js";
import { existsSync } from "fs"; import { existsSync } from "fs";
const app = new Hono(); const app = new Hono();
@ -80,30 +79,23 @@ app.get("/frame/:id", async (c) => {
.get(id) as Frame | undefined; .get(id) as Frame | undefined;
if (!frame) return c.json({ error: "Frame not found" }, 404); if (!frame) return c.json({ error: "Frame not found" }, 404);
const decodingTempDir = getDecodingTempDir(); const decodingTempDir = getDecodingTempDir();
const screenshotsDir = getScreenshotsDir(); const screenshotsDir = getScreenshotsDir();
const videoFilename = frame.videoPath; const videoFilename = frame.videoPath;
const frameIndex = frame.videoFrameIndex; const frameIndex = frame.videoFrameIndex;
const imageFilename = frame.imgFilename; const imageFilename = frame.imgFilename;
const bareVideoFilename = videoFilename?.replace(".mp4", "") || null; const bareVideoFilename = videoFilename?.replace(".mp4", "") || null;
const decodedImageBMP = frameIndex const decodedImage = frameIndex
? `${bareVideoFilename}_${(frameIndex).toString().padStart(4, "0")}.bmp` ? `${bareVideoFilename}_${frameIndex.toString().padStart(4, "0")}.bmp`
: null;
const decodedImagePNG = frameIndex
? `${bareVideoFilename}_${(frameIndex).toString().padStart(4, "0")}.png`
: null; : null;
let returnImagePath = ""; let returnImagePath = "";
let needToBeDecoded = videoFilename !== null && frameIndex !== null && !frame.imgFilename; let needToBeDecoded = videoFilename !== null && frameIndex !== null && !frame.imgFilename;
if (decodedImagePNG && fs.existsSync(join(getDecodingTempDir(), decodedImagePNG))) { if (decodedImage && fs.existsSync(join(getDecodingTempDir(), decodedImage))) {
needToBeDecoded = false; needToBeDecoded = false;
returnImagePath = join(decodingTempDir, decodedImagePNG); returnImagePath = join(decodingTempDir, decodedImage);
} else if (decodedImageBMP && fs.existsSync(join(getDecodingTempDir(), decodedImageBMP))) { } else if (imageFilename && fs.existsSync(join(screenshotsDir, imageFilename))) {
needToBeDecoded = false;
returnImagePath = join(decodingTempDir, decodedImageBMP);
}
else if (imageFilename && fs.existsSync(join(screenshotsDir, imageFilename))) {
returnImagePath = join(screenshotsDir, imageFilename); returnImagePath = join(screenshotsDir, imageFilename);
} }
@ -113,15 +105,15 @@ app.get("/frame/:id", async (c) => {
if (!videoExists) { if (!videoExists) {
return c.json({ error: "Video not found" }, { status: 404 }); return c.json({ error: "Video not found" }, { status: 404 });
} }
// Decode requesting frame immediately, then decode the whole video chunk const decodedFilename = immediatelyExtractFrameFromVideo(
// This allows the user to get an immediate response, videoFilename!,
// and we get prepared for the next few seconds of scrolling (timeline). frameIndex!,
const decodedFilename = immediatelyExtractFrameFromVideo(videoFilename!, frameIndex!, decodingTempDir); decodingTempDir
);
const decodedPath = join(decodingTempDir, decodedFilename); const decodedPath = join(decodingTempDir, decodedFilename);
await waitForFileExists(decodedPath); await waitForFileExists(decodedPath);
extractFramesFromVideo(videoFilename!, null, null, decodingTempDir);
if (existsSync(decodedPath)) { if (existsSync(decodedPath)) {
const imageBuffer = fs.readFileSync(decodedPath); const imageBuffer = fs.readFileSync(decodedPath);
@ -136,10 +128,11 @@ app.get("/frame/:id", async (c) => {
} }
} else { } else {
const imageBuffer = fs.readFileSync(returnImagePath); const imageBuffer = fs.readFileSync(returnImagePath);
const imageMimeType = imageFilename?.endsWith(".png") ? "image/png" : "image/jpeg";
return new Response(imageBuffer, { return new Response(imageBuffer, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "image/png" "Content-Type": imageMimeType
} }
}); });
} }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export * from "./fs/index.js";
export * from "./platform/index.js";
export * from "./video/index.js";
export * from "./network/index.js";

View File

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

View File

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