Compare commits

..

No commits in common. "d4f14b97b0696a00445c7425a26324b15a6fd453" and "a43a563609591819da10509d5ebfee18c1c26e8e" have entirely different histories.

10 changed files with 105 additions and 406 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -1,87 +1,85 @@
{ {
"name": "openrewind", "name": "openrewind",
"version": "0.6.0", "version": "0.6.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",
"scripts": { "scripts": {
"dev": "cross-env NODE_ENV=dev bun run dev:all", "dev": "cross-env NODE_ENV=dev bun run dev:all",
"dev:all": "concurrently -n=react,electron -c='#ff3e00',blue \"bun run dev:react\" \"bun run dev:electron\"", "dev:all": "concurrently -n=react,electron -c='#ff3e00',blue \"bun run dev:react\" \"bun run dev:electron\"",
"dev:react": "vite dev", "dev:react": "vite dev",
"dev:electron": "bunx gulp build && electron dist/electron/index.js", "dev:electron": "bunx gulp build && electron dist/electron/index.js",
"build:react": "vite build", "build:react": "vite build",
"build:app": "bunx gulp build", "build:app": "bunx gulp build",
"build:electron": "electron-builder", "build:electron": "electron-builder",
"format": "bunx prettier --write ." "format": "bunx prettier --write ."
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@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",
"@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", "detect-port": "^2.1.0",
"detect-port": "^2.1.0", "electron-context-menu": "^4.0.4",
"electron-context-menu": "^4.0.4", "electron-reloader": "^1.2.3",
"electron-reloader": "^1.2.3", "electron-screencapture": "^1.1.0",
"electron-screencapture": "^1.1.0", "electron-serve": "^2.1.1",
"electron-serve": "^2.1.1", "electron-store": "^10.0.0",
"electron-store": "^10.0.0", "electron-window-state": "^5.0.3",
"electron-window-state": "^5.0.3", "execa": "^9.5.1",
"execa": "^9.5.1", "hono": "^4.6.15",
"hono": "^4.6.15", "i18next": "^24.0.2",
"i18next": "^24.0.2", "i18next-browser-languagedetector": "^8.0.0",
"i18next-browser-languagedetector": "^8.0.0", "i18next-electron-fs-backend": "^3.0.2",
"i18next-electron-fs-backend": "^3.0.2", "i18next-fs-backend": "^2.6.0",
"i18next-fs-backend": "^2.6.0", "i18next-icu": "^2.3.0",
"i18next-icu": "^2.3.0", "image-size": "^1.1.1",
"image-size": "^1.1.1", "memory-cache": "^0.2.0",
"jotai": "^2.11.0", "react": "^18.3.1",
"memory-cache": "^0.2.0", "react-dom": "^18.3.1",
"react": "^18.3.1", "react-i18next": "^15.1.2",
"react-dom": "^18.3.1", "react-router": "^7.0.1",
"react-i18next": "^15.1.2", "react-router-dom": "^7.0.1",
"react-router": "^7.0.1", "screenshot-desktop": "^1.15.0",
"react-router-dom": "^7.0.1", "sqlite3": "^5.1.7",
"screenshot-desktop": "^1.15.0", "sqlstring": "^2.3.3",
"sqlite3": "^5.1.7", "vite-tsconfig-paths": "^5.1.3"
"sqlstring": "^2.3.3", },
"vite-tsconfig-paths": "^5.1.3" "devDependencies": {
}, "@electron/rebuild": "^3.7.1",
"devDependencies": { "@eslint/js": "^9.13.0",
"@electron/rebuild": "^3.7.1", "@iconify-icon/react": "^2.1.0",
"@eslint/js": "^9.13.0", "@types/better-sqlite3": "^7.6.12",
"@iconify-icon/react": "^2.1.0", "@types/gulp": "^4.0.17",
"@types/better-sqlite3": "^7.6.12", "@types/memory-cache": "^0.2.6",
"@types/gulp": "^4.0.17", "@types/react": "^18.3.12",
"@types/memory-cache": "^0.2.6", "@types/react-dom": "^18.3.1",
"@types/react": "^18.3.12", "@types/screenshot-desktop": "^1.12.3",
"@types/react-dom": "^18.3.1", "@types/sqlstring": "^2.3.2",
"@types/screenshot-desktop": "^1.12.3", "@vitejs/plugin-react": "^4.3.3",
"@types/sqlstring": "^2.3.2", "autoprefixer": "^10.4.19",
"@vitejs/plugin-react": "^4.3.3", "concurrently": "^9.0.1",
"autoprefixer": "^10.4.19", "cross-env": "^7.0.3",
"concurrently": "^9.0.1", "del": "^8.0.0",
"cross-env": "^7.0.3", "electron": "^33.2.0",
"del": "^8.0.0", "electron-build": "^0.0.3",
"electron": "^33.2.0", "electron-builder": "^25.1.8",
"electron-build": "^0.0.3", "eslint": "^9.13.0",
"electron-builder": "^25.1.8", "eslint-plugin-react-hooks": "^5.0.0",
"eslint": "^9.13.0", "eslint-plugin-react-refresh": "^0.4.14",
"eslint-plugin-react-hooks": "^5.0.0", "globals": "^15.11.0",
"eslint-plugin-react-refresh": "^0.4.14", "gulp": "^5.0.0",
"globals": "^15.11.0", "gulp-clean": "^0.4.0",
"gulp": "^5.0.0", "gulp-typescript": "6.0.0-alpha.1",
"gulp-clean": "^0.4.0", "postcss": "^8.4.38",
"gulp-typescript": "6.0.0-alpha.1", "tailwindcss": "^3.4.15",
"postcss": "^8.4.38", "typescript": "~5.6.2",
"tailwindcss": "^3.4.15", "typescript-eslint": "^8.11.0",
"typescript": "~5.6.2", "vite": "^5.4.10",
"typescript-eslint": "^8.11.0", "vite-plugin-chunk-split": "^0.5.0"
"vite": "^5.4.10", }
"vite-plugin-chunk-split": "^0.5.0"
}
} }

View File

@ -1,264 +1,9 @@
import "./index.css"; import "./index.css";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Frame } from "../../src/electron/backend/schema.d.ts";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import localizedFormat from "dayjs/plugin/localizedFormat";
import updateLocale from "dayjs/plugin/updateLocale";
import { useAtomValue } from "jotai";
import { apiInfoAtom } from "src/renderer/state/apiInfo.ts";
dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
dayjs.extend(updateLocale);
dayjs.updateLocale("en", {
relativeTime: {
future: "in %s",
past: "%s ago",
s: "%d seconds",
m: "1 minute",
mm: "%d minutes",
h: "1 hour",
hh: "%d hours",
d: "1 day",
dd: "%d days",
M: "1 month",
MM: "%d months",
y: "1 year",
yy: "%d years"
}
});
function Image({ src }: { src: string }) {
return (
<img
src={src}
alt="Current frame"
className="w-full h-full object-cover absolute inset-0"
/>
);
}
export default function RewindPage() { export default function RewindPage() {
const { port, apiKey } = useAtomValue(apiInfoAtom);
const [timeline, setTimeline] = useState<Frame[]>([]);
const [currentFrameId, setCurrentFrameId] = useState<number | null>(null);
const [images, setImages] = useState<Record<number, string>>({});
const [isLoadingMore, setIsLoadingMore] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const lastAvaliableFrameId = useRef<number | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const updatedTimes = useRef<number>(0);
const loadingQueue = useRef<number[]>([]);
const isProcessingQueue = useRef(false);
const processQueue = useCallback(async () => {
if (!port || isProcessingQueue.current || loadingQueue.current.length === 0) return;
isProcessingQueue.current = true;
const frameId = loadingQueue.current.shift()!;
try {
const startUpdateTimes = updatedTimes.current;
const response = await fetch(`http://localhost:${port}/frame/${frameId}`, {
headers: {
"x-api-key": apiKey
}
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setImages((prev) => {
const newImages = { ...prev, [frameId]: url };
if (updatedTimes.current <= startUpdateTimes) {
lastAvaliableFrameId.current = frameId;
updatedTimes.current++;
}
return newImages;
});
} catch (error) {
console.error(error);
} finally {
isProcessingQueue.current = false;
setTimeout(() => {
processQueue();
}, 500);
}
}, [apiKey, port]);
const loadImage = useCallback(
(frameId: number) => {
if (!port || images[frameId]) return;
// Add to queue if not already in it
if (!loadingQueue.current.includes(frameId)) {
loadingQueue.current.push(frameId);
// preserve up to 5 tasks in the queue
loadingQueue.current = loadingQueue.current.slice(-5);
}
// Start processing if not already running
if (!isProcessingQueue.current) {
processQueue();
}
},
[images, port, processQueue]
);
// Load current frame after 400ms of inactivity
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (currentFrameId) {
timeoutRef.current = setTimeout(() => {
loadImage(currentFrameId);
}, 400);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [currentFrameId, loadImage]);
// Fetch timeline data
const fetchTimeline = useCallback(
async (untilID?: number) => {
if (!port) return;
try {
const url = new URL(`http://localhost:${port}/timeline`);
if (untilID) {
url.searchParams.set("untilID", untilID.toString());
}
const response = await fetch(url.toString(), {
headers: {
"x-api-key": apiKey
}
});
const data = await response.json();
setTimeline((prev) => (untilID ? [...prev, ...data] : data));
} catch (error) {
console.error(error);
}
},
[port, apiKey]
);
useEffect(() => {
fetchTimeline();
}, [fetchTimeline]);
// Load initial images
useEffect(() => {
if (timeline.length > 0 && !currentFrameId) {
setCurrentFrameId(timeline[0].id);
loadImage(timeline[0].id);
if (timeline.length > 1) {
loadImage(timeline[1].id);
}
}
}, [timeline, currentFrameId, loadImage]);
const lastScrollTime = useRef(Date.now());
const handleScroll = (e: React.WheelEvent) => {
if (!containerRef.current || !currentFrameId) return;
// Only allow scroll changes every 80ms
const now = Date.now();
if (now - lastScrollTime.current < 80) return;
lastScrollTime.current = now;
const delta = Math.sign(e.deltaY);
const currentIndex = timeline.findIndex((frame) => frame.id === currentFrameId);
if (currentIndex === -1) return;
const newIndex = Math.min(Math.max(currentIndex - delta, 0), timeline.length - 1);
const newFrameId = timeline[newIndex].id;
console.log(currentFrameId, lastAvaliableFrameId);
if (newFrameId !== currentFrameId) {
setCurrentFrameId(newFrameId);
// Preload adjacent images
if (newIndex > 0) loadImage(timeline[newIndex - 1].id);
if (newIndex < timeline.length - 1) loadImage(timeline[newIndex + 1].id);
// Load more timeline data when we're near the end
if (newIndex > timeline.length - 10 && !isLoadingMore) {
setIsLoadingMore(true);
const lastID = timeline[timeline.length - 1].id;
fetchTimeline(lastID).finally(() => setIsLoadingMore(false));
}
}
};
function displayTime(time: number) {
// if diff < 1h, fromNow()
// else use localized format
const diff = dayjs().diff(dayjs.unix(time), "second");
if (diff < 3600) {
return dayjs.unix(time).fromNow();
} else {
return dayjs.unix(time).format("llll");
}
}
return ( return (
<div <>
ref={containerRef} <div className="w-screen h-screen relative dark:text-white"></div>
className="w-screen h-screen relative dark:text-white overflow-hidden" </>
onWheel={handleScroll}
>
{/* Current image */}
<Image
src={
currentFrameId
? images[currentFrameId] ||
(lastAvaliableFrameId.current ? images[lastAvaliableFrameId.current] : "")
: ""
}
/>
{/* Time capsule */}
<div
className="absolute bottom-8 left-8 bg-zinc-800 text-white bg-opacity-80 backdrop-blur-lg
rounded-full px-4 py-2 text-xl"
>
{currentFrameId
? displayTime(
timeline.find((frame) => frame.id === currentFrameId)!.createdAt
) || "Loading..."
: "Loading..."}
</div>
{/* Timeline */}
<div
className="absolute top-8 right-8 h-5/6 overflow-hidden bg-zinc-800 bg-opacity-80 backdrop-blur-lg
px-4 py-2 text-xl"
>
{timeline
.filter((frame) => frame.id <= currentFrameId!)
.map((frame) => (
<div
key={frame.id}
className="flex items-center gap-2"
onClick={() => {
setCurrentFrameId(frame.id);
loadImage(frame.id);
}}
>
<span className="mt-2 text-base text-zinc-400">#{frame.id}</span>
<span className="text-white">
{dayjs().diff(dayjs.unix(frame.createdAt), "second")} sec ago
</span>
</div>
))}
</div>
</div>
); );
} }

View File

@ -99,12 +99,6 @@ app.on("ready", () => {
cache.put("server:APIKey", key); cache.put("server:APIKey", key);
} }
serve({ fetch: honoApp.fetch, port: port }); serve({ fetch: honoApp.fetch, port: port });
// Send API info to renderer
settingsWindow?.webContents.send("api-info", {
port,
apiKey: key
});
console.log(`App server running on port ${port}`); console.log(`App server running on port ${port}`);
}); });
}); });
@ -132,10 +126,3 @@ app.on("will-quit", () => {
ipcMain.on("close-settings", () => { ipcMain.on("close-settings", () => {
settingsWindow?.hide(); settingsWindow?.hide();
}); });
ipcMain.handle("request-api-info", () => {
return {
port: cache.get("server:port"),
apiKey: cache.get("server:APIKey")
};
});

View File

@ -1,5 +1,20 @@
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("appGlobal", { // Expose protected methods that allow the renderer process to use
requestApiInfo: () => ipcRenderer.invoke("request-api-info") // the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("api", {
send: (channel, data) => {
// whitelist channels
let validChannels = ["toMain"];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
},
receive: (channel, func) => {
let validChannels = ["fromMain"];
if (validChannels.includes(channel)) {
// Deliberately strip event as it includes `sender`
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
}
}); });

View File

@ -17,7 +17,3 @@ contextBridge.exposeInMainWorld("settingsWindow", {
ipcRenderer.send("close-settings", {}); ipcRenderer.send("close-settings", {});
} }
}); });
contextBridge.exposeInMainWorld("appGlobal", {
requestApiInfo: () => ipcRenderer.invoke("request-api-info")
});

View File

@ -1,5 +1,4 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors";
import cache from "memory-cache"; import cache from "memory-cache";
import { join } from "path"; import { join } from "path";
import fs from "fs"; import fs from "fs";
@ -16,8 +15,6 @@ import { existsSync } from "fs";
const app = new Hono(); const app = new Hono();
app.use("*", cors());
app.use(async (c, next) => { app.use(async (c, next) => {
const key = cache.get("server:APIKey"); const key = cache.get("server:APIKey");
if (key && c.req.header("x-api-key") !== key) { if (key && c.req.header("x-api-key") !== key) {
@ -48,7 +45,7 @@ function getFramesUntilID(db: Database, untilID: number, limit = 50): Frame[] {
` `
SELECT id, createdAt, imgFilename, videoPath, videoFrameIndex SELECT id, createdAt, imgFilename, videoPath, videoFrameIndex
FROM frame FROM frame
WHERE id < ? WHERE id <= ?
ORDER BY createdAt DESC ORDER BY createdAt DESC
LIMIT ? LIMIT ?
` `
@ -120,9 +117,6 @@ app.get("/frame/:id", async (c) => {
if (existsSync(decodedPath)) { if (existsSync(decodedPath)) {
const imageBuffer = fs.readFileSync(decodedPath); const imageBuffer = fs.readFileSync(decodedPath);
setTimeout(() => {
fs.unlinkSync(decodedPath);
}, 1000);
return new Response(imageBuffer, { return new Response(imageBuffer, {
status: 200, status: 200,
headers: { headers: {

View File

@ -36,7 +36,7 @@ export function immediatelyExtractFrameFromVideo(
const outputFilename = `${bareVideoFilename}_${frameIndex.toString().padStart(4, "0")}.bmp`; const outputFilename = `${bareVideoFilename}_${frameIndex.toString().padStart(4, "0")}.bmp`;
const outputPathArg = join(outputPath, outputFilename); const outputPathArg = join(outputPath, outputFilename);
const args = [ const args = [
"-ss", "-ss",
`${formatTime(frameIndex / ENCODING_FRAME_RATE)}`, `${formatTime(frameIndex / ENCODING_FRAME_RATE)}`,
"-i", "-i",
`${fullVideoPath}`, `${fullVideoPath}`,

View File

@ -3,38 +3,8 @@ import SettingsPage from "pages/settings";
import "./i18n.ts"; import "./i18n.ts";
import RewindPage from "pages/rewind"; import RewindPage from "pages/rewind";
import "./app.css"; import "./app.css";
import { useEffect } from "react";
import { useAtom } from "jotai";
import { apiInfoAtom } from "./state/apiInfo.ts";
declare global {
interface Window {
appGlobal: {
requestApiInfo: () => Promise<{ port: number; apiKey: string }>;
};
}
}
export function App() { export function App() {
const [apiInfo, setApiInfo] = useAtom(apiInfoAtom);
useEffect(() => {
const fetchApiInfo = async () => {
try {
const info = await window.appGlobal.requestApiInfo();
setApiInfo(info);
} catch (error) {
console.error("Failed to fetch API info:", error);
}
};
fetchApiInfo();
}, [setApiInfo]);
if (!apiInfo) {
return null;
}
return ( return (
<div className="w-screen h-screen"> <div className="w-screen h-screen">
<HashRouter> <HashRouter>

View File

@ -1,6 +0,0 @@
import { atom } from 'jotai'
export const apiInfoAtom = atom({
port: -1,
apiKey: ''
})