Compare commits

...

10 Commits

Author SHA1 Message Date
252776e0a7
version: 0.10.0 2025-01-31 11:53:16 +08:00
2ce726d16c
update: scheduler can now prevent selected tasks from running under high load 2025-01-25 23:25:11 +08:00
96b4cecaec
fix: incorrect logic when removing files 2025-01-21 03:40:40 +08:00
94bd14db52
update: remove maxInterval in scheduler
fix: unlinking a file without handling the case of file not exist
2025-01-21 02:12:17 +08:00
fb0c60a71e
add: blurred background in rewind page when ratio mismatch 2025-01-21 01:37:20 +08:00
3d76a5ece5
add: type for recognition 2025-01-06 23:42:11 +08:00
094f8a0925
add: scheduler 2025-01-06 23:14:42 +08:00
496c00e7e3
update: directory structure for binary files 2025-01-06 21:51:48 +08:00
95fa5b4ac7
update: README.md 2025-01-02 01:02:31 +08:00
ee5dff0d6f
update: README.md 2025-01-02 00:59:46 +08:00
15 changed files with 410 additions and 91 deletions

View File

@ -2,12 +2,12 @@
OpenRewind is an open-source alternative to [rewind.ai](https://rewind.ai), forked from [OpenRecall](https://github.com/openrecall/openrecall). OpenRewind is an open-source alternative to [rewind.ai](https://rewind.ai), forked from [OpenRecall](https://github.com/openrecall/openrecall).
We wanted to create an open source app that provides similar core functionality We want to create an open source app that provides similar core functionality
to rewind.ai, and that app is **OpenRewind**. to rewind.ai, and that app is **OpenRewind**.
## Alpha Release: 0.8.0 ## Alpha Release: 0.8.0
Latest results: There is an Alpha version available! We currently only support Apple Silicon Macs. Latest works: There is an Alpha version available! We currently only support Apple Silicon Macs.
(Of course, thanks to building on Electron, there will definitely be support for multiple platforms in the beta/stable release) (Of course, thanks to building on Electron, there will definitely be support for multiple platforms in the beta/stable release)
### ✨ Features ### ✨ Features
@ -20,7 +20,7 @@ Latest results: There is an Alpha version available! We currently only support A
## To-dos ## To-dos
## OCR optimized for the specific platform ### OCR optimized for the specific platform
We will use the OCR API provided by the OS for macOS and Windows. We will use the OCR API provided by the OS for macOS and Windows.
@ -29,9 +29,9 @@ Reference projects:
> We [forked](https://github.com/alikia2x/ocrit) this project to suit our needs > We [forked](https://github.com/alikia2x/ocrit) this project to suit our needs
- [Windows.Media.Ocr.Cli](https://github.com/zh-h/Windows.Media.Ocr.Cli) - [Windows.Media.Ocr.Cli](https://github.com/zh-h/Windows.Media.Ocr.Cli)
## Big-little architecture optimizations for Apple Silicon ### Big-little architecture optimizations for Apple Silicon
We wrote a small Swift program that allows a given program to run at a selected QoS energy class. On ARM Macs, this means we can offload some work (such as video encoding) to energy-efficient cores, reducing peak CPU usage and power consumption. We wrote a small Swift program that allows a given program to run at a selected QoS class. On ARM Mac, this means we can put some work (such as video encoding) to Efficient cores, reducing peak CPU usage and power consumption.
> See: [Prioritize Work with Quality of Service Classes](https://developer.apple.com/library/archive/documentation/Performance/Conceptual/EnergyGuide-iOS/PrioritizeWorkWithQoS.html) > See: [Prioritize Work with Quality of Service Classes](https://developer.apple.com/library/archive/documentation/Performance/Conceptual/EnergyGuide-iOS/PrioritizeWorkWithQoS.html)

BIN
bun.lockb

Binary file not shown.

View File

@ -1,6 +1,5 @@
import gulp from "gulp"; import gulp from "gulp";
import ts from "gulp-typescript"; import ts from "gulp-typescript";
// @ts-ignore
import clean from "gulp-clean"; import clean from "gulp-clean";
import fs from "fs"; import fs from "fs";
@ -30,7 +29,7 @@ gulp.task("assets", () => {
}); });
gulp.task("binary", () => { gulp.task("binary", () => {
return gulp.src("bin/**/*", { encoding: false }).pipe(gulp.dest("dist/electron/bin")); return gulp.src(`bin/${process.platform}-${process.arch}/**/*`, { encoding: false }).pipe(gulp.dest("dist/electron/bin"));
}); });
gulp.task("locales", () => { gulp.task("locales", () => {

View File

@ -1,6 +1,6 @@
{ {
"name": "openrewind", "name": "openrewind",
"version": "0.8.0", "version": "0.10.0",
"type": "module", "type": "module",
"description": "Your second brain, superpowered.", "description": "Your second brain, superpowered.",
"main": "dist/electron/index.js", "main": "dist/electron/index.js",
@ -21,6 +21,7 @@
"@alikia/random-key": "npm:@jsr/alikia__random-key", "@alikia/random-key": "npm:@jsr/alikia__random-key",
"@electron/remote": "^2.1.2", "@electron/remote": "^2.1.2",
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"@types/node-os-utils": "^1.3.4",
"@unly/universal-language-detector": "^2.0.3", "@unly/universal-language-detector": "^2.0.3",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
@ -41,6 +42,7 @@
"image-size": "^1.1.1", "image-size": "^1.1.1",
"jotai": "^2.11.0", "jotai": "^2.11.0",
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
"node-os-utils": "^1.3.7",
"pino": "^9.6.0", "pino": "^9.6.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@ -34,7 +34,7 @@ function Image({ src }: { src: string }) {
<img <img
src={src} src={src}
alt="Current frame" alt="Current frame"
className="w-full h-full object-cover absolute inset-0" className="w-full h-full object-contain absolute inset-0"
/> />
); );
} }
@ -181,8 +181,6 @@ export default function RewindPage() {
const newIndex = Math.min(Math.max(currentIndex - delta, 0), timeline.length - 1); const newIndex = Math.min(Math.max(currentIndex - delta, 0), timeline.length - 1);
const newFrameId = timeline[newIndex].id; const newFrameId = timeline[newIndex].id;
console.log(currentFrameId, lastAvaliableFrameId);
if (newFrameId !== currentFrameId) { if (newFrameId !== currentFrameId) {
setCurrentFrameId(newFrameId); setCurrentFrameId(newFrameId);
// Preload adjacent images // Preload adjacent images
@ -212,9 +210,19 @@ export default function RewindPage() {
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="w-screen h-screen relative dark:text-white overflow-hidden" className="w-screen h-screen relative dark:text-white overflow-hidden bg-black"
onWheel={handleScroll} onWheel={handleScroll}
> >
<img
src={currentFrameId
? images[currentFrameId] ||
(lastAvaliableFrameId.current ? images[lastAvaliableFrameId.current] : "")
: ""}
alt="background"
className="w-full h-full object-cover absolute inset-0 blur-lg"
/>
{/* Current image */} {/* Current image */}
<Image <Image
src={ src={
@ -225,6 +233,8 @@ export default function RewindPage() {
} }
/> />
{/* Time capsule */} {/* Time capsule */}
<div <div
className="absolute bottom-8 left-8 bg-zinc-800 text-white bg-opacity-80 backdrop-blur-lg className="absolute bottom-8 left-8 bg-zinc-800 text-white bg-opacity-80 backdrop-blur-lg

View File

@ -1,4 +1,3 @@
import { Database } from "better-sqlite3";
import { exec } from "child_process"; import { exec } from "child_process";
import fs from "fs"; import fs from "fs";
import path, { join } from "path"; import path, { join } from "path";
@ -8,19 +7,22 @@ import { getEncodeCommand } from "../utils/index.js";
import { getRecordingsDir, getEncodingTempDir, getScreenshotsDir } from "../utils/index.js"; import { getRecordingsDir, getEncodingTempDir, getScreenshotsDir } from "../utils/index.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";
import { getDatabase } from "../utils/index.js";
const THREE_MINUTES = 180; const THREE_MINUTES = 180;
const MIN_FRAMES_TO_ENCODE = THREE_MINUTES * FRAME_RATE; const MIN_FRAMES_TO_ENCODE = THREE_MINUTES * FRAME_RATE;
const CONCURRENCY = 1; const CONCURRENCY = 1;
// Detect and insert encoding tasks // Detect and insert encoding tasks
export function checkFramesForEncoding(db: Database) { export function checkFramesForEncoding() {
const db = getDatabase();
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT id, imgFilename, createdAt SELECT id, imgFilename, createdAt
FROM frame FROM frame
WHERE encodeStatus = 0 AND imgFilename IS NOT NULL WHERE encodeStatus = 0
ORDER BY createdAt ASC; AND imgFilename IS NOT NULL
`); ORDER BY createdAt;
`);
const frames = stmt.all() as Frame[]; const frames = stmt.all() as Frame[];
const buffer: Frame[] = []; const buffer: Frame[] = [];
@ -32,12 +34,12 @@ export function checkFramesForEncoding(db: Database) {
const lastFramePath = join(getScreenshotsDir(), lastFrame.imgFilename!); const lastFramePath = join(getScreenshotsDir(), lastFrame.imgFilename!);
if (!fs.existsSync(framePath)) { if (!fs.existsSync(framePath)) {
console.warn("File not exist:", frame.imgFilename); console.warn("File not exist:", frame.imgFilename);
deleteFrameFromDB(db, frame.id); deleteFrameFromDB(frame.id);
continue; continue;
} }
if (!fs.existsSync(lastFramePath)) { if (!fs.existsSync(lastFramePath)) {
console.warn("File not exist:", lastFrame.imgFilename); console.warn("File not exist:", lastFrame.imgFilename);
deleteFrameFromDB(db, lastFrame.id); deleteFrameFromDB(lastFrame.id);
continue; continue;
} }
const currentFrameSize = sizeOf(framePath); const currentFrameSize = sizeOf(framePath);
@ -51,20 +53,24 @@ export function checkFramesForEncoding(db: Database) {
if (chunkConditionSatisfied) { if (chunkConditionSatisfied) {
// Create new encoding task // Create new encoding task
const taskStmt = db.prepare(` const taskStmt = db.prepare(`
INSERT INTO encoding_task (status) VALUES (0); INSERT INTO encoding_task (status)
VALUES (0);
`); `);
const taskId = taskStmt.run().lastInsertRowid; const taskId = taskStmt.run().lastInsertRowid;
// Insert frames into encoding_task_data // Insert frames into encoding_task_data
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO encoding_task_data (encodingTaskID, frame) VALUES (?, ?); INSERT INTO encoding_task_data (encodingTaskID, frame)
VALUES (?, ?);
`); `);
for (const frame of buffer) { for (const frame of buffer) {
insertStmt.run(taskId, frame.id); insertStmt.run(taskId, frame.id);
db.prepare( db.prepare(
` `
UPDATE frame SET encodeStatus = 1 WHERE id = ?; UPDATE frame
` SET encodeStatus = 1
WHERE id = ?;
`
).run(frame.id); ).run(frame.id);
} }
console.log(`Created encoding task ${taskId} with ${buffer.length} frames`); console.log(`Created encoding task ${taskId} with ${buffer.length} frames`);
@ -73,28 +79,39 @@ export function checkFramesForEncoding(db: Database) {
} }
} }
function deleteEncodedScreenshots(db: Database) { function deleteEncodedScreenshots() {
const db = getDatabase();
// TODO: double-check that the frame was really encoded into the video // TODO: double-check that the frame was really encoded into the video
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT * FROM frame WHERE encodeStatus = 2 AND imgFilename IS NOT NULL; SELECT *
FROM frame
WHERE encodeStatus = 2
AND imgFilename IS NOT NULL;
`); `);
const frames = stmt.all() as Frame[]; const frames = stmt.all() as Frame[];
for (const frame of frames) { for (const frame of frames) {
if (!frame.imgFilename) continue; const imgPath = path.join(getScreenshotsDir(), frame.imgFilename!);
fs.unlinkSync(path.join(getScreenshotsDir(), frame.imgFilename)); if (fs.existsSync(imgPath)) {
fs.unlinkSync(imgPath);
}
const updateStmt = db.prepare(` const updateStmt = db.prepare(`
UPDATE frame SET imgFilename = NULL WHERE id = ?; UPDATE frame
SET imgFilename = NULL
WHERE id = ?;
`); `);
updateStmt.run(frame.id); updateStmt.run(frame.id);
} }
} }
function _deleteNonExistentScreenshots(db: Database) { function _deleteNonExistentScreenshots() {
const db = getDatabase();
const screenshotDir = getScreenshotsDir(); const screenshotDir = getScreenshotsDir();
const filesInDir = new Set(fs.readdirSync(screenshotDir)); const filesInDir = new Set(fs.readdirSync(screenshotDir));
const dbStmt = db.prepare(` const dbStmt = db.prepare(`
SELECT imgFilename FROM frame WHERE imgFilename IS NOT NULL; SELECT imgFilename
FROM frame
WHERE imgFilename IS NOT NULL;
`); `);
const dbFiles = dbStmt.all() as { imgFilename: string }[]; const dbFiles = dbStmt.all() as { imgFilename: string }[];
const dbFileSet = new Set(dbFiles.map((f) => f.imgFilename)); const dbFileSet = new Set(dbFiles.map((f) => f.imgFilename));
@ -107,14 +124,17 @@ function _deleteNonExistentScreenshots(db: Database) {
} }
} }
export async function deleteUnnecessaryScreenshots(db: Database) { export async function deleteUnnecessaryScreenshots() {
deleteEncodedScreenshots(db); deleteEncodedScreenshots();
//deleteNonExistentScreenshots(db); //deleteNonExistentScreenshots();
} }
export function deleteFrameFromDB(db: Database, id: number) { export function deleteFrameFromDB(id: number) {
const db = getDatabase();
const deleteStmt = db.prepare(` const deleteStmt = db.prepare(`
DELETE FROM frame WHERE id = ?; DELETE
FROM frame
WHERE id = ?;
`); `);
deleteStmt.run(id); deleteStmt.run(id);
console.log(`Deleted frame ${id} from database`); console.log(`Deleted frame ${id} from database`);
@ -136,16 +156,16 @@ function createMetaFile(frames: Frame[]) {
} }
// Check and process encoding task // Check and process encoding task
export function processEncodingTasks(db: Database) { export function processEncodingTasks() {
const db = getDatabase();
let tasksPerforming = getTasksPerforming(); let tasksPerforming = getTasksPerforming();
if (tasksPerforming.length >= CONCURRENCY) return; if (tasksPerforming.length >= CONCURRENCY) return;
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT id, status SELECT id, status
FROM encoding_task FROM encoding_task
WHERE status = 0 WHERE status = 0 LIMIT ?
LIMIT ? `);
`);
const tasks = stmt.all(CONCURRENCY - tasksPerforming.length) as EncodingTask[]; const tasks = stmt.all(CONCURRENCY - tasksPerforming.length) as EncodingTask[];
@ -156,17 +176,19 @@ export function processEncodingTasks(db: Database) {
// Update task status as processing (1) // Update task status as processing (1)
const updateStmt = db.prepare(` const updateStmt = db.prepare(`
UPDATE encoding_task SET status = 1 WHERE id = ? UPDATE encoding_task
`); SET status = 1
WHERE id = ?
`);
updateStmt.run(taskId); updateStmt.run(taskId);
const framesStmt = db.prepare(` const framesStmt = db.prepare(`
SELECT frame.imgFilename, frame.id SELECT frame.imgFilename, frame.id
FROM encoding_task_data FROM encoding_task_data
JOIN frame ON encoding_task_data.frame = frame.id JOIN frame ON encoding_task_data.frame = frame.id
WHERE encoding_task_data.encodingTaskID = ? WHERE encoding_task_data.encodingTaskID = ?
ORDER BY frame.createdAt ASC ORDER BY frame.createdAt
`); `);
const frames = framesStmt.all(taskId) as Frame[]; const frames = framesStmt.all(taskId) as Frame[];
const metaFilePath = path.join(getEncodingTempDir(), `${taskId}_meta.txt`); const metaFilePath = path.join(getEncodingTempDir(), `${taskId}_meta.txt`);
@ -186,13 +208,19 @@ export function processEncodingTasks(db: Database) {
console.log(`Video ${videoPath} created successfully`); console.log(`Video ${videoPath} created successfully`);
// Update task status to complete (2) // Update task status to complete (2)
const completeStmt = db.prepare(` const completeStmt = db.prepare(`
UPDATE encoding_task SET status = 2 WHERE id = ? UPDATE encoding_task
`); SET status = 2
WHERE id = ?
`);
completeStmt.run(taskId); completeStmt.run(taskId);
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) { for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
const frame = frames[frameIndex]; const frame = frames[frameIndex];
const updateFrameStmt = db.prepare(` const updateFrameStmt = db.prepare(`
UPDATE frame SET videoPath = ?, videoFrameIndex = ?, encodeStatus = 2 WHERE id = ? UPDATE frame
SET videoPath = ?,
videoFrameIndex = ?,
encodeStatus = 2
WHERE id = ?
`); `);
updateFrameStmt.run(`${taskId}.mp4`, frameIndex, frame.id); updateFrameStmt.run(`${taskId}.mp4`, frameIndex, frame.id);
} }

View File

@ -2,17 +2,17 @@ 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/index.js"; import { getBinDir, getDatabaseDir } from "../utils/index.js";
import { migrate } from "./migrate/index.js"; import { migrate } from "./migrate/index.js";
function getLibSimpleExtensionPath() { function getLibSimpleExtensionPath() {
switch (process.platform) { switch (process.platform) {
case "win32": case "win32":
return path.join(__dirname, "bin", process.platform, "libsimple", "simple.dll"); return path.join(getBinDir(), "libsimple", "simple.dll");
case "darwin": case "darwin":
return path.join(__dirname, "bin", process.platform, "libsimple", "libsimple.dylib"); return path.join(getBinDir(), "libsimple", "libsimple.dylib");
case "linux": case "linux":
return path.join(__dirname, "bin", process.platform, "libsimple", "libsimple.so"); return path.join(getBinDir(), "libsimple", "libsimple.so");
default: default:
throw new Error("Unsupported platform"); throw new Error("Unsupported platform");
} }
@ -142,7 +142,7 @@ function init(db: Database) {
export async function initDatabase() { export async function initDatabase() {
const dbPath = getDatabaseDir(); const dbPath = getDatabaseDir();
const db = new DB(dbPath, { verbose: console.log }); const db = new DB(dbPath);
const libSimpleExtensionPath = getLibSimpleExtensionPath(); const libSimpleExtensionPath = getLibSimpleExtensionPath();
db.loadExtension(libSimpleExtensionPath); db.loadExtension(libSimpleExtensionPath);

View File

@ -0,0 +1,13 @@
type RecognitionResult = RecognitionLine[];
type Pixels = number;
type OriginX = Pixels;
type OriginY = Pixels;
type Width = Pixels;
type Height = Pixels;
type Coordinates = [OriginX, OriginY, Width, Height];
interface RecognitionLine {
text: string;
confidence?: number | number[];
position?: Coordinates;
}

View File

@ -0,0 +1,253 @@
import osu from "node-os-utils";
type TaskId = string;
type TaskFunction = () => void;
interface Task {
id: TaskId;
func: TaskFunction;
interval?: number;
lastRun?: number;
nextRun?: number;
isPaused: boolean;
delayUntil?: number;
requiredSystemState: SystemState;
}
export interface TaskStatus {
status: "NOT_FOUND" | "PAUSED" | "DELAYED" | "SCHEDULED" | "IDLE";
until?: string;
nextRun?: string;
}
type SystemState = "ANY" | "LOW_POWER" | "IDLE";
export class Scheduler {
private tasks: Map<TaskId, Task> = new Map();
private timer: NodeJS.Timeout | null = null;
private monitorTimer: NodeJS.Timeout | null = null;
private cpuUsage: number = 0;
constructor(private readonly minTickInterval: number = 500) {
this.start();
}
private start(): void {
this.scheduleNextTick();
this.monitorTimer = setInterval(() => this.monitor(), 1000);
}
private monitor(): void {
osu.cpu.usage().then((cpuPercentage) => {
this.cpuUsage = cpuPercentage / 100;
})
}
private scheduleNextTick(): void {
if (this.timer) {
clearTimeout(this.timer);
}
const now = Date.now();
let nextTick = now + this.minTickInterval;
for (const task of this.tasks.values()) {
const isTaskPaused = task.isPaused;
const isTaskDelayed = task.delayUntil && now < task.delayUntil;
if (isTaskPaused || isTaskDelayed) {
continue;
}
const nextTaskEarlierThanNextTick = task.nextRun && task.nextRun < nextTick;
if (nextTaskEarlierThanNextTick) {
nextTick = task.nextRun!;
}
}
const delay = Math.max(0, nextTick - now);
this.timer = setTimeout(() => this.tick(), delay);
}
private tickSingleTask(
task: Task,
getNextTick: () => number,
updateNextTick: (nextTick: number) => void
): void {
const now = Date.now();
const isTaskPaused = task.isPaused;
const isTaskDelayed = task.delayUntil && now < task.delayUntil;
if (isTaskPaused || isTaskDelayed) {
return;
}
const taskRequiredLowPower = task.requiredSystemState === "LOW_POWER";
const cpuUsage = this.cpuUsage;
const isSystemLowPower = cpuUsage < 0.75;
const isTaskReadyForLowPowerRun = taskRequiredLowPower ? isSystemLowPower : true;
const reachedTaskNextRun = task.interval && task.nextRun && now >= task.nextRun;
const isTaskReadyForIntervalRun = reachedTaskNextRun && isTaskReadyForLowPowerRun;
if (!isTaskReadyForLowPowerRun) {
this.delayTask(task.id, 1000)
}
if (isTaskReadyForIntervalRun) {
task.func();
task.lastRun = now;
task.nextRun = now + task.interval!;
}
const isTaskNextRunEarlierThanNextTick = task.nextRun && task.nextRun < getNextTick();
if (isTaskNextRunEarlierThanNextTick) {
updateNextTick(task.nextRun!);
}
}
private tick(): void {
const now = Date.now();
let nextTick = now + this.minTickInterval;
for (const task of this.tasks.values()) {
this.tickSingleTask(
task,
() => nextTick,
(v) => (nextTick = v)
);
}
this.scheduleNextTick();
}
/**
* Add a new task to the scheduler.
*
* @param id A unique string identifier for the task.
* @param func The function to be executed by the task.
* @param interval The interval (in milliseconds) between task executions.
* @param requiredSystemState The required system state for the task to run.
*/
addTask(id: TaskId, func: TaskFunction, interval?: number, requiredSystemState: SystemState = "ANY"): void {
this.tasks.set(id, {
id,
func,
interval,
isPaused: false,
lastRun: undefined,
nextRun: interval ? Date.now() + interval : undefined,
requiredSystemState: requiredSystemState,
});
this.scheduleNextTick();
}
/**
* Trigger a task to execute immediately, regardless of its current state.
*
* If the task is paused or delayed, it will not be executed.
*
* @param id The unique string identifier for the task.
*/
triggerTask(id: TaskId): void {
const task = this.tasks.get(id);
if (task && !task.isPaused && (!task.delayUntil || Date.now() >= task.delayUntil)) {
task.func();
task.lastRun = Date.now();
if (task.interval) {
task.nextRun = Date.now() + task.interval;
}
}
this.scheduleNextTick();
}
/**
* Pause a task, so that it will not be executed until it is resumed.
*
* @param id The unique string identifier for the task.
*/
pauseTask(id: TaskId): void {
const task = this.tasks.get(id);
if (task) {
task.isPaused = true;
}
this.scheduleNextTick();
}
/**
* Resume a paused task, so that it can be executed according to its interval.
*
* @param id The unique string identifier for the task.
*/
resumeTask(id: TaskId): void {
const task = this.tasks.get(id);
if (task) {
task.isPaused = false;
}
this.scheduleNextTick();
}
/**
* Delay a task from being executed for a specified amount of time.
*
* @param id The unique string identifier for the task.
* @param delayMs The amount of time in milliseconds to delay the task's execution.
*/
delayTask(id: TaskId, delayMs: number): void {
const task = this.tasks.get(id);
if (task) {
task.delayUntil = Date.now() + delayMs;
if (task.nextRun) {
task.nextRun += delayMs;
}
}
this.scheduleNextTick();
}
setTaskInterval(id: TaskId, interval: number): void {
const task = this.tasks.get(id);
if (task) {
task.interval = interval;
task.nextRun = Date.now() + interval;
}
this.scheduleNextTick();
}
getTaskStatus(id: TaskId): TaskStatus {
const task = this.tasks.get(id);
if (!task) {
return { status: "NOT_FOUND" };
}
if (task.isPaused) {
return { status: "PAUSED" };
}
if (task.delayUntil && Date.now() < task.delayUntil) {
return {
status: "DELAYED",
until: new Date(task.delayUntil).toLocaleString()
};
}
if (task.nextRun) {
return {
status: "SCHEDULED",
nextRun: new Date(task.nextRun).toLocaleString()
};
}
return { status: "IDLE" };
}
stop(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.monitorTimer) {
clearTimeout(this.monitorTimer);
this.monitorTimer = null;
}
}
}

View File

@ -1,25 +1,23 @@
import screenshot from "screenshot-desktop"; import screenshot from "screenshot-desktop";
import { getScreenshotsDir } from "../utils/index.js"; import { getDatabase, getScreenshotsDir } from "../utils/index.js";
import { join } from "path"; import { join } from "path";
import { Database } from "better-sqlite3";
import SqlString from "sqlstring"; import SqlString from "sqlstring";
export function startScreenshotLoop(db: Database) { export function takeScreenshot() {
return setInterval(() => { const db = getDatabase();
const timestamp = new Date().getTime(); const timestamp = new Date().getTime();
const screenshotDir = getScreenshotsDir(); const screenshotDir = getScreenshotsDir();
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(() => { .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]
); );
db.exec(SQL); db.exec(SQL);
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
}); });
}, 2000);
} }

View File

@ -14,7 +14,7 @@ import initI18n from "./i18n.js";
import { createMainWindow, createSettingsWindow } from "./createWindow.js"; import { createMainWindow, createSettingsWindow } from "./createWindow.js";
import { initDatabase } from "./backend/init.js"; import { initDatabase } from "./backend/init.js";
import { Database } from "better-sqlite3"; import { Database } from "better-sqlite3";
import { startScreenshotLoop } from "./backend/screenshot.js"; import { takeScreenshot } from "./backend/screenshot.js";
import { __dirname } from "./dirname.js"; import { __dirname } from "./dirname.js";
import { hideDock } from "./utils/index.js"; import { hideDock } from "./utils/index.js";
import { import {
@ -27,16 +27,17 @@ import { serve } from "@hono/node-server";
import { findAvailablePort } from "./utils/index.js"; import { findAvailablePort } from "./utils/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";
import { Scheduler } from "./backend/scheduler.js";
const i18n = initI18n(); const i18n = initI18n();
const t = i18n.t.bind(i18n); const t = i18n.t.bind(i18n);
const port = process.env.PORT || "5173"; const port = process.env.PORT || "5173";
const dev = !app.isPackaged; const dev = !app.isPackaged;
const scheduler = new Scheduler();
let tray: null | Tray = null; let tray: null | Tray = null;
let dbConnection: null | Database = null; let dbConnection: null | Database = null;
let screenshotInterval: null | NodeJS.Timeout = null;
let mainWindow: BrowserWindow | null; let mainWindow: BrowserWindow | null;
let settingsWindow: BrowserWindow | null; let settingsWindow: BrowserWindow | null;
@ -109,10 +110,10 @@ app.on("ready", () => {
}); });
}); });
initDatabase().then((db) => { initDatabase().then((db) => {
screenshotInterval = startScreenshotLoop(db); scheduler.addTask("screenshot", takeScreenshot, 2000);
setInterval(checkFramesForEncoding, 5000, db); scheduler.addTask("check-encoding", checkFramesForEncoding, 5000);
setInterval(processEncodingTasks, 10000, db); scheduler.addTask("process-encoding", processEncodingTasks, 10000, "LOW_POWER");
setInterval(deleteUnnecessaryScreenshots, 20000, db); scheduler.addTask("delete-screenshots", deleteUnnecessaryScreenshots, 20000);
dbConnection = db; dbConnection = db;
cache.put("server:dbConnection", dbConnection); cache.put("server:dbConnection", dbConnection);
}); });
@ -126,7 +127,7 @@ app.on("ready", () => {
app.on("will-quit", () => { app.on("will-quit", () => {
dbConnection?.close(); dbConnection?.close();
if (screenshotInterval) clearInterval(screenshotInterval); scheduler.stop();
}); });
ipcMain.on("close-settings", () => { ipcMain.on("close-settings", () => {
@ -134,8 +135,8 @@ ipcMain.on("close-settings", () => {
}); });
ipcMain.handle("request-api-info", () => { ipcMain.handle("request-api-info", () => {
return { return {
port: cache.get("server:port"), port: cache.get("server:port"),
apiKey: cache.get("server:APIKey") apiKey: cache.get("server:APIKey")
}; };
}); });

View File

@ -0,0 +1,6 @@
import { Database } from "better-sqlite3";
import cache from "memory-cache";
export function getDatabase(): Database {
return cache.get("server:dbConnection");
}

View File

@ -1,6 +1,7 @@
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { getUserDataDir } from "../platform/index.js"; import { getUserDataDir } from "../platform/index.js";
import { __dirname } from "../../dirname.js";
export function createDataDir() { export function createDataDir() {
const dataDir = getUserDataDir(); const dataDir = getUserDataDir();
@ -68,6 +69,10 @@ export function getLogDir() {
return logDir; return logDir;
} }
export function getBinDir() {
return path.join(__dirname, "bin");
}
export async function waitForFileExists(filePath: string, timeout: number = 10000): Promise<void> { export async function waitForFileExists(filePath: string, timeout: number = 10000): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.access(filePath, fs.constants.F_OK, (err) => { fs.access(filePath, fs.constants.F_OK, (err) => {

View File

@ -2,4 +2,5 @@ export * from "./fs/index.js";
export * from "./platform/index.js"; export * from "./platform/index.js";
export * from "./video/index.js"; export * from "./video/index.js";
export * from "./network/index.js"; export * from "./network/index.js";
export * from "./logging/index.js"; export * from "./logging/index.js";
export * from "./backend/index.js";

View File

@ -1,8 +1,7 @@
import { join } from "path"; import { join } from "path";
import os from "os"; import os from "os";
import { app } from "electron"; import { app } from "electron";
import { __dirname } from "../../dirname.js"; import { getBinDir, logger } from "../index.js";
import { logger } from "../index.js";
export function getUserDataDir() { export function getUserDataDir() {
switch (process.platform) { switch (process.platform) {
@ -41,13 +40,13 @@ export function getFFmpegPath() {
let path = ""; let path = "";
switch (process.platform) { switch (process.platform) {
case "win32": case "win32":
path = join(__dirname, "bin", process.platform, "ffmpeg.exe"); path = join(getBinDir(), "ffmpeg.exe");
break; break;
case "darwin": case "darwin":
path = join(__dirname, "bin", process.platform, "ffmpeg"); path = join(getBinDir(), "ffmpeg");
break; break;
case "linux": case "linux":
path = join(__dirname, "bin", process.platform, "ffmpeg"); path = join(getBinDir(), "ffmpeg");
break; break;
default: default:
throw new Error("Unsupported platform"); throw new Error("Unsupported platform");
@ -55,3 +54,7 @@ export function getFFmpegPath() {
logger.info("FFmpeg path: %s", path); logger.info("FFmpeg path: %s", path);
return path; return path;
} }
export function getOCRitPath() {
const path = join(getBinDir(), "ocrit");
}