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

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

View File

@ -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() {

View File

@ -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]

View File

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

View File

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

View File

@ -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();
@ -87,23 +86,16 @@ app.get("/frame/:id", async (c) => {
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);
}
@ -114,14 +106,14 @@ app.get("/frame/:id", async (c) => {
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
}
});
}

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