ref: utils dir
This commit is contained in:
parent
97dce81297
commit
a43a563609
@ -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;
|
||||||
|
@ -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";
|
||||||
|
|
||||||
|
@ -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() {
|
||||||
|
@ -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]
|
||||||
|
@ -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();
|
||||||
|
@ -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";
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
97
src/electron/utils/fs/index.ts
Normal file
97
src/electron/utils/fs/index.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
4
src/electron/utils/index.ts
Normal file
4
src/electron/utils/index.ts
Normal 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";
|
50
src/electron/utils/platform/index.ts
Normal file
50
src/electron/utils/platform/index.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
81
src/electron/utils/video/index.ts
Normal file
81
src/electron/utils/video/index.ts
Normal 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;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user