diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 45536e5..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,36 +0,0 @@
-# dependencies (bun install)
-node_modules
-
-# output
-out
-dist
-*.tgz
-
-# code coverage
-coverage
-*.lcov
-
-# logs
-logs
-_.log
-report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
-
-# dotenv environment variable files
-.env
-.env.development.local
-.env.test.local
-.env.production.local
-.env.local
-
-# caches
-.eslintcache
-.cache
-*.tsbuildinfo
-
-# IntelliJ based IDEs
-.idea
-
-# Finder (MacOS) folder config
-.DS_Store
-
-**/CLAUDE.md
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 6a89a57..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Build/Test Commands
-- Build/Run: `bun start`
-- Type Check: `bun run typecheck`
-
-## Code Style Guidelines
-- TypeScript with strict mode enabled
-- ESM modules with `.ts` extension
-- Error handling: Use try/catch blocks with specific error messages
-- Function/interface documentation with JSDoc-style comments
-- Clear type definitions with descriptive interfaces
-- CLI arguments use both short (-o) and long (--out-dir) forms
-- Error messages should be concise and actionable
-- Use async/await pattern for asynchronous code
-- Prefer early returns for validation errors
-- Command parsing follows the conventions in cli.ts
-- Don't add comments to self-explanatory code
-- Always run checks / tests before confirming something is complete
\ No newline at end of file
diff --git a/README.md b/README.md
deleted file mode 100644
index 1aebb3e..0000000
--- a/README.md
+++ /dev/null
@@ -1,118 +0,0 @@
-# gitlapse
-
-Create timelapse videos of your web project's development.
-
-## Installation
-
-```bash
-# Clone and install
-git clone https://github.com/miniware/gitlapse.git
-cd gitlapse
-bun install
-
-# Optional: install globally
-bun run build
-chmod +x install.sh
-./install.sh
-```
-
-## Usage
-
-Run gitlapse from the root directory of a JavaScript web project:
-
-```bash
-gitlapse [options]
-```
-
-Or without global installation:
-
-```bash
-bun start [options]
-```
-
-### Options
-
-- `-h, --help`: Show help message
-- `-o, --out-dir
`: Output directory for frames and video (default: ./timelapse)
-- `-w, --width `: Screenshot width (default: 1440)
-- `--height `: Screenshot height (default: 720)
-- `--wait-before `: Wait time before page load (default: 3000)
-- `--wait-after `: Wait time after page load before screenshot (default: 0)
-- `-r, --route `: Route to capture (default: /)
-- `-p, --port `: Port to use for the dev server (default: 3000)
-- `--branch `: Only include commits from this branch (default: all)
-- `--max-commits `: Limit number of commits to process
-- `--fps `: Frames per second in output video (default: 12)
-- `--density `: Screenshot pixel density (default: 2)
-- `--fullscreen`: Capture full page height in screenshots
-
-### Examples
-
-Create a timelapse with default settings:
-
-```bash
-gitlapse
-```
-
-Create a timelapse with custom width and height:
-
-```bash
-gitlapse --width 1920 --height 1080
-```
-
-Create a high-resolution timelapse with fullscreen capture:
-
-```bash
-gitlapse --density 2 --fullscreen
-```
-
-## How It Works
-
-1. Checks out each commit in your git history
-2. Installs dependencies for that commit
-3. Starts the development server
-4. Takes a screenshot of the specified route
-5. Compiles all screenshots into a video
-
-### Resume Feature
-
-If the process is interrupted, gitlapse can resume from where it left off:
-
-- When you run gitlapse and it finds existing frames in the output directory,
- you'll be prompted to resume from the last processed commit or start over
-- This is useful for long projects where the process might be interrupted,
- or when you encounter errors and need to fix them before continuing
-
-## Requirements
-
-- [Bun](https://bun.sh) v1.0+
-- [Git](https://git-scm.com/)
-- [ffmpeg](https://ffmpeg.org/) (for video generation)
-- [ImageMagick](https://imagemagick.org/) (for fullscreen mode)
-- A JavaScript web project with package.json and a dev or start script
-
-## New Features
-
-### High Resolution Screenshots
-
-Use the `--density` option to create high-resolution screenshots:
-
-```bash
-gitlapse --density 2
-```
-
-This creates screenshots with double the pixel density, resulting in sharper images.
-
-### Fullscreen Capture
-
-Use the `--fullscreen` option to capture the full height of each page:
-
-```bash
-gitlapse --fullscreen
-```
-
-When this option is enabled:
-- Each screenshot captures the entire page height (not just the visible viewport)
-- The resulting video uses the height of the tallest screenshot
-- All frames are aligned to the top of the video
-- Black background is used to fill any empty space
diff --git a/gitlapse b/gitlapse
new file mode 100755
index 0000000..121b385
Binary files /dev/null and b/gitlapse differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..3ec68bb
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,27 @@
+module github.com/miniware/gitlapse
+
+go 1.24.4
+
+require github.com/charmbracelet/bubbletea v1.3.5
+
+require (
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/lipgloss v1.1.0 // indirect
+ github.com/charmbracelet/x/ansi v0.8.0 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sync v0.13.0 // indirect
+ golang.org/x/sys v0.32.0 // indirect
+ golang.org/x/text v0.3.8 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f9453b6
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,45 @@
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
+github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
+github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
diff --git a/install.sh b/install.sh
deleted file mode 100755
index 4ab0967..0000000
--- a/install.sh
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/bin/bash
-
-# Exit on error
-set -e
-
-# Check if running on macOS
-if [[ "$(uname)" != "Darwin" ]]; then
- echo "This install script is designed for macOS only."
- exit 1
-fi
-
-# Determine script directory
-SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-cd "$SCRIPT_DIR"
-
-# Build the executable
-echo "Building gitlapse executable..."
-bun run build
-
-# Create bin directory in user home if it doesn't exist
-BIN_DIR="$HOME/.local/bin"
-mkdir -p "$BIN_DIR"
-
-# Create symlink
-SYMLINK_PATH="$BIN_DIR/gitlapse"
-echo "Creating symlink at $SYMLINK_PATH..."
-ln -sf "$SCRIPT_DIR/dist/gitlapse" "$SYMLINK_PATH"
-
-# Check if .local/bin is in PATH
-if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
- # Determine which shell config to use
- SHELL_CONFIG=""
- if [[ -f "$HOME/.zshrc" ]]; then
- SHELL_CONFIG="$HOME/.zshrc"
- elif [[ -f "$HOME/.bashrc" ]]; then
- SHELL_CONFIG="$HOME/.bashrc"
- fi
-
- if [[ -n "$SHELL_CONFIG" ]]; then
- echo "Adding $BIN_DIR to your PATH in $SHELL_CONFIG..."
- echo "export PATH=\"\$PATH:$BIN_DIR\"" >> "$SHELL_CONFIG"
- echo "Please run: source $SHELL_CONFIG"
- else
- echo "Warning: Could not find shell config file. Please manually add $BIN_DIR to your PATH."
- fi
-fi
-
-echo "Installation complete! You can now use gitlapse command from anywhere."
-echo "Tip: If gitlapse command is not found, restart your terminal or run: source $SHELL_CONFIG"
\ No newline at end of file
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..6da20f6
--- /dev/null
+++ b/main.go
@@ -0,0 +1,63 @@
+package main
+
+// A simple program that opens the alternate screen buffer and displays mouse
+// coordinates and events.
+
+import (
+ "log"
+ "os/exec"
+ "strconv"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type model struct {
+ mouseEvent tea.MouseEvent
+ commits []string
+}
+
+type commits []string
+
+type step string
+
+func (m model) Init() tea.Cmd {
+ return getCommits
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ if s := msg.String(); s == "ctrl+c" || s == "q" || s == "esc" {
+ return m, tea.Quit
+
+ }
+ case commits:
+ m.commits = msg
+ }
+
+ return m, nil
+}
+
+func getCommits() tea.Msg {
+ logs, err := exec.Command("git", strings.Split("log --oneline --no-decorate", " ")...).Output()
+ if err != nil {
+ panic(err)
+ }
+ return commits(strings.Split(string(logs), "\n"))
+}
+
+func (m model) View() string {
+ s := "(Press q to quit.)\n\n"
+
+ s += "Commits found:" + strconv.Itoa(len(m.commits))
+
+ return s
+}
+
+func main() {
+ p := tea.NewProgram(model{})
+ if _, err := p.Run(); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/notes.md b/notes.md
new file mode 100644
index 0000000..6546567
--- /dev/null
+++ b/notes.md
@@ -0,0 +1,4 @@
+# Goals
+
+- get git history
+- see if package exists
diff --git a/package.json b/package.json
deleted file mode 100644
index 4f7aa00..0000000
--- a/package.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "name": "gitlapse",
- "version": "0.1.0",
- "description": "Create timelapse videos of your web project's development",
- "module": "src/index.ts",
- "type": "module",
- "files": ["dist/", "src/"],
- "bin": {
- "gitlapse": "./dist/gitlapse"
- },
- "scripts": {
- "start": "bun src/index.ts",
- "typecheck": "tsc --noEmit",
- "test": "bun test",
- "build": "bun build src/index.ts --compile --outfile ./dist/gitlapse"
- },
- "devDependencies": {
- "@types/bun": "latest",
- "typescript": "^5"
- },
- "dependencies": {
- "puppeteer": "^24.7.2"
- },
- "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
-}
diff --git a/src/cli.ts b/src/cli.ts
deleted file mode 100644
index d6bc36c..0000000
--- a/src/cli.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import path from "path";
-
-export interface CliConfig {
- outDir: string;
- width: number;
- height: number;
- waitBeforeMs: number;
- waitAfterMs: number;
- route: string;
- port: number;
- branch?: string;
- maxCommits?: number;
- fps: number;
- density: number;
- fullscreen: boolean;
-}
-
-export function parseArgs(
- args: string[],
- cwd: string = process.cwd()
-): CliConfig {
- let outDir = path.join(cwd, 'timelapse');
- let width = 1440;
- let height = 720;
- let waitBeforeMs = 3000;
- let waitAfterMs = 0;
- let route = "/";
- let port = 3000;
- let branch: string | undefined = undefined;
- let maxCommits: number | undefined = undefined;
- let fps = 12;
- let density = 2; // Default to high resolution (2x)
- let fullscreen = false;
-
- for (let i = 0; i < args.length; i++) {
- const arg = args[i];
- switch (arg) {
- case "-o":
- case "--out-dir":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- outDir = path.resolve(cwd, args[i] || "");
- break;
- case "-w":
- case "--width":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- width = parseInt(args[i] || "0", 10);
- break;
- case "-h":
- case "--height":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- height = parseInt(args[i] || "0", 10);
- break;
- case "--wait-before":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- waitBeforeMs = parseInt(args[i] || "0", 10);
- break;
- case "--wait-after":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- waitAfterMs = parseInt(args[i] || "0", 10);
- break;
- case "-r":
- case "--route":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- const routeArg = args[i] || "";
- route = routeArg.startsWith("/") ? routeArg : `/${routeArg}`;
- break;
- case "-p":
- case "--port":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- port = parseInt(args[i] || "3000", 10);
- break;
- case "--branch":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- branch = args[i];
- break;
- case "--max-commits":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- maxCommits = parseInt(args[i] || "0", 10);
- break;
- case "--fps":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- fps = parseInt(args[i] || "12", 10);
- break;
- case "--density":
- if (i + 1 >= args.length) {
- throw new Error(`Missing value for ${arg}`);
- }
- i++;
- density = parseFloat(args[i] || "2");
- break;
- case "--fullscreen":
- fullscreen = true;
- break;
- // Help is handled in index.ts
- default:``
- throw new Error(`Unknown argument: ${arg}`);
- }
- }
-
- return { outDir, width, height, waitBeforeMs, waitAfterMs, route, port, branch, maxCommits, fps, density, fullscreen };
-}
-
-export interface Scripts {
- [key: string]: string;
-}
-
-export function detectServeCommand(scripts: Scripts): string {
- if (scripts.dev) return "bun run dev";
- if (scripts.start) return "bun run start";
- throw new Error("No 'dev' or 'start' script found in package.json.");
-}
\ No newline at end of file
diff --git a/src/file-utils.ts b/src/file-utils.ts
deleted file mode 100644
index 627d954..0000000
--- a/src/file-utils.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import fs from "fs";
-import path from "path";
-import { execSync } from "child_process";
-
-interface FileUtilDependencies {
- existsSync: typeof fs.existsSync;
- /**
- * Optional readdirSync function for reading directory entries.
- * If not provided, execCommand will be used as a fallback.
- */
- readdirSync?: typeof fs.readdirSync;
- execCommand: (...args: any[]) => any;
-}
-
-const defaultDependencies: FileUtilDependencies = {
- existsSync: fs.existsSync,
- readdirSync: fs.readdirSync,
- execCommand: execSync
-};
-
-/**
- * Finds the last processed frame in the output directory
- * @param outDir - The output directory path
- * @param framesPattern - The pattern used for frame filenames
- * @param deps - Optional dependencies to inject (useful for testing)
- * @returns The highest frame number found, or -1 if no frames found
- */
-export function findLastProcessedFrame(
- outDir: string,
- framesPattern: string,
- deps: FileUtilDependencies = defaultDependencies
-): number {
- const { existsSync, readdirSync, execCommand } = deps;
- if (!existsSync(outDir)) {
- return -1;
- }
- let entries: string[];
- try {
- if (readdirSync) {
- const allFiles = readdirSync(outDir);
- const prefix = path.basename(framesPattern);
- entries = allFiles.filter(
- f => f.startsWith(prefix) && f.endsWith('.png')
- );
- } else {
- // Fallback to execCommand if readdirSync not provided (e.g., in tests)
- const framePattern = `${framesPattern}*.png`;
- entries = execCommand(
- `ls ${framePattern} 2>/dev/null || echo ""`
- )
- .toString()
- .trim()
- .split("\n")
- .filter(Boolean);
- }
- } catch (error) {
- return -1;
- }
- if (entries.length === 0) {
- return -1;
- }
- let highestFrameNumber = -1;
- for (const frame of entries) {
- const match = frame.match(/frame_(\d+)_/);
- if (match && match[1]) {
- const frameNumber = parseInt(match[1]);
- if (frameNumber > highestFrameNumber) {
- highestFrameNumber = frameNumber;
- }
- }
- }
- return highestFrameNumber;
-}
-
-/**
- * Gets information about the last processed commit
- * @param commitIndex - The index of the last processed commit
- * @param commits - Array of commit SHAs
- * @param deps - Optional dependencies to inject (useful for testing)
- * @returns Object with commit details, or undefined if commit not found
- */
-export function getLastCommitInfo(
- commitIndex: number,
- commits: string[],
- deps: { execCommand: (...args: any[]) => any } = { execCommand: execSync }
-): { sha: string; shortSha: string; message: string } | undefined {
- try {
- const { execCommand } = deps;
-
- if (commitIndex < 0 || commitIndex >= commits.length) {
- return undefined;
- }
-
- const sha = commits[commitIndex];
- if (!sha) {
- return undefined;
- }
-
- const message = execCommand(`git show -s --format=%s ${sha}`).toString().trim();
- return {
- sha,
- shortSha: sha.substring(0, 8),
- message
- };
- } catch (error) {
- return undefined;
- }
-}
\ No newline at end of file
diff --git a/src/git-utils.ts b/src/git-utils.ts
deleted file mode 100644
index 7a191a2..0000000
--- a/src/git-utils.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-import { execSync } from "child_process";
-import { log, pretty } from "./logger";
-import fs from "fs";
-import path from "path";
-
-/**
- * Check if we're in a git repo and the repo is in a safe state
- */
-export function safetyCheck(outDir = "timelapse"): void {
- log("Running git repository safety check");
-
- // Check if we're in a git repo
- if (!require('fs').existsSync(require('path').join(process.cwd(), ".git"))) {
- console.error("ERROR: No .git directory found. Run from repo root.");
- process.exit(1);
- }
-
- checkForDetachedHead();
- checkForUncommittedChanges();
-}
-
-/**
- * Check for detached HEAD, rebase, or merge in progress
- */
-export function checkForDetachedHead(): void {
- try {
- const gitStatus = execSync("git status").toString().trim();
- if (gitStatus.includes("detached HEAD") ||
- gitStatus.includes("rebase in progress") ||
- gitStatus.includes("merge in progress")) {
- console.error("ERROR: Git repository is in a detached HEAD state or in the middle of a rebase/merge.");
- console.error("Please complete any ongoing git operations before running this tool.");
- process.exit(1);
- }
- } catch (error) {
- console.error("ERROR: Unable to check git status. Is this a valid git repository?");
- process.exit(1);
- }
-}
-
-/**
- * Check for uncommitted changes
- */
-export function checkForUncommittedChanges(): void {
- try {
- // Ensure no unstaged or staged changes
- execSync('git diff --quiet');
- execSync('git diff --cached --quiet');
- } catch {
- console.error("ERROR: You have uncommitted changes in this repository.");
- console.error("Please commit or stash your changes before running this tool.");
- console.error("This tool requires a clean working directory.");
- process.exit(1);
- }
-}
-
-/**
- * Forcibly clean any changes to Gemfile.lock before checkout
- */
-export function cleanGemfileLock(): void {
- const gemfileLockPath = path.join(process.cwd(), "Gemfile.lock");
- if (fs.existsSync(gemfileLockPath)) {
- try {
- // Check if Gemfile.lock has changes
- try {
- execSync('git diff --quiet Gemfile.lock');
- } catch {
- // Gemfile.lock has changes, reset it
- log("Resetting changes to Gemfile.lock before checkout");
- execSync('git checkout -- Gemfile.lock');
- }
- } catch (error) {
- // Log but don't fail the process
- log(`Warning: Failed to reset Gemfile.lock: ${error instanceof Error ? error.message : String(error)}`);
- }
- }
-}
-
-/**
- * Gather commits from git history
- */
-export function gatherCommits(branch?: string, maxCommits?: number): string[] {
- log("Gathering commits from git history");
- let commitCmd = "git log --all --date-order --pretty=format:%H"; // Get all commits from all branches in chronological order
-
- if (branch) {
- commitCmd = `git log ${branch} --date-order --pretty=format:%H`;
- log(`Using branch restriction: ${branch}`);
- }
-
- log(`Running git command: ${commitCmd}`);
-
- let commits = execSync(commitCmd)
- .toString()
- .trim()
- .split("\n")
- .filter(Boolean)
- .reverse(); // Reverse to get oldest commits first
-
- log(`Found ${commits.length} total commits in history`);
-
- // Limit number of commits if requested
- if (maxCommits && maxCommits > 0 && commits.length > maxCommits) {
- log(`Limiting to ${maxCommits} commits as requested`);
- commits = commits.slice(0, maxCommits);
- }
-
- return commits;
-}
-
-/**
- * Get the current branch name
- * @returns Current branch name or empty string if error
- */
-export function getCurrentBranch(): string {
- try {
- return execSync("git rev-parse --abbrev-ref HEAD").toString().trim();
- } catch (error) {
- console.error("ERROR: Failed to determine current branch");
- console.error(error instanceof Error ? error.message : String(error));
- process.exit(1);
- }
-}
-
-/**
- * Restore repository to original state with fallbacks
- */
-export async function restoreRepositoryState(originalBranch: string): Promise {
- log(`Restoring repository state`);
- try {
- // First try to restore original branch if known
- if (originalBranch) {
- try {
- execSync(`git checkout ${originalBranch} --quiet`);
- log(`Restored original branch: ${originalBranch}`);
- return;
- } catch (branchError) {
- log(`Failed to restore original branch: ${originalBranch}`);
- // Will try fallbacks below
- }
- }
-
- // Try fallbacks in order
- if (await tryCheckoutHead()) return;
- if (await tryCheckoutMain()) return;
- if (await tryCheckoutMaster()) return;
-
- // If all fallbacks fail
- pretty(`❌ ERROR: Failed all attempts to restore repository state`, "error");
- pretty(`Please manually run: git checkout ${originalBranch || 'main'}`, "error");
- } catch (error) {
- pretty(`❌ CRITICAL ERROR: Failed to restore repository state`, "error");
- pretty(`Please manually run: git checkout ${originalBranch || 'main'}`, "error");
- }
-}
-
-/**
- * Try to checkout HEAD
- */
-export async function tryCheckoutHead(): Promise {
- try {
- execSync(`git checkout HEAD --quiet`);
- log(`Restored repository to HEAD`);
- return true;
- } catch (headError) {
- return false;
- }
-}
-
-/**
- * Try to checkout main branch
- */
-export async function tryCheckoutMain(): Promise {
- try {
- execSync(`git checkout main --quiet`);
- log(`Restored repository to main branch`);
- return true;
- } catch (mainError) {
- return false;
- }
-}
-
-/**
- * Try to checkout master branch
- */
-export async function tryCheckoutMaster(): Promise {
- try {
- execSync(`git checkout master --quiet`);
- log(`Restored repository to master branch`);
- return true;
- } catch (masterError) {
- return false;
- }
-}
-
-/**
- * Setup process exit handler for safety
- */
-export function setupSafetyExitHandler(originalBranch: string): void {
- // Clean up on process exit signals
- ["SIGINT", "SIGTERM", "SIGHUP", "uncaughtException"].forEach(signal => {
- process.on(signal, () => {
- log("⚠️ Process interrupted, restoring repository state");
-
- try {
- // First try to restore original branch if known
- if (originalBranch) {
- try {
- execSync(`git checkout ${originalBranch} --quiet`);
- log(`Restored original branch: ${originalBranch}`);
- process.exit(1);
- return;
- } catch (branchError) {
- console.error(`ERROR: Failed to restore original branch ${originalBranch}`);
- console.error(branchError instanceof Error ? branchError.message : String(branchError));
- // Will try fallbacks below
- }
- }
-
- // Fallback 1: Try to checkout HEAD
- try {
- execSync(`git checkout HEAD --quiet`);
- log(`Restored repository to HEAD`);
- process.exit(1);
- return;
- } catch (headError) {
- // Ignore and try next fallback
- }
-
- // Fallback 2: Try to checkout main branch
- try {
- execSync(`git checkout main --quiet`);
- log(`Restored repository to main branch`);
- process.exit(1);
- return;
- } catch (mainError) {
- // Ignore and try next fallback
- }
-
- // Fallback 3: Try to checkout master branch
- try {
- execSync(`git checkout master --quiet`);
- log(`Restored repository to master branch`);
- } catch (masterError) {
- console.error(`ERROR: Failed all attempts to restore repository state`);
- }
- } catch (finalError) {
- console.error(`CRITICAL ERROR during cleanup: ${finalError}`);
- }
-
- process.exit(1);
- });
- });
-}
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100755
index dc291da..0000000
--- a/src/index.ts
+++ /dev/null
@@ -1,648 +0,0 @@
-#!/usr/bin/env bun
-
-import fs from "fs";
-import path from "path";
-import puppeteer from "puppeteer";
-import { parseArgs, detectServeCommand } from "./cli";
-import { log, pretty } from "./logger";
-import { execSync } from "child_process";
-import { getUserConfirmation } from "./user-interaction";
-import { checkResumeAndPrompt } from "./resume-utils";
-import { findCapturedFrames, generateTimeLapseVideo } from "./video-utils";
-import {
- safetyCheck,
- getCurrentBranch,
- setupSafetyExitHandler,
- gatherCommits,
- restoreRepositoryState,
- cleanGemfileLock
-} from "./git-utils";
-import {
- startServer,
- stopServer
-} from "./server-utils";
-import {
- navigateWithRetry,
- isResponseSuccessful,
- isErrorPage,
- isEmptyPage,
- extractErrorMessage,
- saveScreenshot,
- handleNavigationError
-} from "./screenshot-utils";
-import {
- detectPackageManager,
- installDependencies,
- checkPackageJsonChanges,
- cleanupEnvironment
-} from "./package-utils";
-
-// Show help information
-function showHelp() {
- console.log(`
- gitlapse - Create timelapse videos of your web project's development
-
- Usage: gitlapse [options]
-
- Options:
- -h, --help Show this help message
- -o, --out-dir Output directory for frames and video (default: ./timelapse)
- -w, --width Screenshot width (default: 1440)
- --height Screenshot height (default: 720)
- --wait-before Wait time before page load (default: 3000)
- --wait-after Wait time after page load before screenshot (default: 0)
- -r, --route Route to capture (default: /)
- -p, --port Port to use for the dev server (default: 3000)
- --branch Only include commits from this branch (default: all)
- --max-commits Limit number of commits to process
- --fps Frames per second in output video (default: 12)
- --density Screenshot pixel density (default: 2)
- --fullscreen Capture full page height in screenshots
-
- Features:
- - Automatic resume: If existing frames are found in the output directory,
- you'll be prompted to resume from where you left off or start over
- `);
-}
-
-// Track original branch for safety (read-only)
-let originalBranch: string = "";
-// Track project type
-let projectType: "js" | "rails" | "hybrid" = "js";
-
-async function main() {
- log("Starting gitlapse");
-
- // Show help if requested
- if (process.argv.includes('--help') || process.argv.includes('-h')) {
- log("Showing help and exiting");
- showHelp();
- return;
- }
-
- // CLI args
- log("Parsing command line arguments");
- const args = process.argv.slice(2);
- const config = parseArgs(args);
- const { outDir, width, height, waitBeforeMs, waitAfterMs, route, port, density, fullscreen } = config;
- log(`Config: outDir=${outDir}, width=${width}, height=${height}, waitBeforeMs=${waitBeforeMs}, waitAfterMs=${waitAfterMs}, route=${route}, port=${port}, density=${density}, fullscreen=${fullscreen}`);
-
- // Run safety checks
- safetyCheck(outDir);
- log("Repository safety checks passed");
-
- // Detect project type based on configuration files
- log("Detecting project type...");
- const pkgPath = path.join(process.cwd(), "package.json");
- const gemfilePath = path.join(process.cwd(), "Gemfile");
- const hasPackageJson = fs.existsSync(pkgPath);
- const hasGemfile = fs.existsSync(gemfilePath);
-
- // Determine project type based on available files
- if (hasGemfile && hasPackageJson) {
- projectType = "hybrid";
- log("Detected hybrid project (Rails + JS)");
- } else if (hasGemfile) {
- projectType = "rails";
- log("Detected Rails project");
- } else if (hasPackageJson) {
- projectType = "js";
- log("Detected JavaScript project");
- } else {
- console.error("No package.json or Gemfile found. This tool supports JS apps with package.json or Rails apps with Gemfile.");
- process.exit(1);
- }
-
- // Create output directory if it doesn't exist
- log(`Creating output directory if needed: ${outDir}`);
- if (!fs.existsSync(outDir)) {
- fs.mkdirSync(outDir, { recursive: true });
- log(`Created output directory: ${outDir}`);
- } else {
- log(`Output directory already exists: ${outDir}`);
- }
-
- // Determine serve command based on project type
- let serveCmd = "";
-
- switch (projectType) {
- case "rails":
- log("Using Rails server command");
- serveCmd = "bundle exec rails server";
- log(`Using Rails serve command: ${serveCmd}`);
- break;
-
- case "js":
- case "hybrid":
- // For hybrid projects, prefer the JS server command
- log("Reading package.json to determine serve command");
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
- serveCmd = detectServeCommand(pkg.scripts || {});
- log(`Detected serve command: ${serveCmd}`);
- break;
-
- default:
- console.error("Unsupported project type");
- process.exit(1);
- }
-
- const url = `http://localhost:${port}`;
- const framesPattern = path.join(outDir, "frame_");
- log(`URL will be: ${url}${route}`);
- log(`Frame pattern: ${framesPattern}`);
-
- // Gather commits
- const commits = gatherCommits(config.branch, config.maxCommits);
-
- if (commits.length === 0) {
- console.error("No commits found in repository history.");
- process.exit(1);
- }
- log(`Will process ${commits.length} commits`);
-
- // Log first few commits to aid debugging
- log("First few commits to process:");
- commits.slice(0, Math.min(5, commits.length)).forEach((sha, i) => {
- const shortSha = sha.substring(0, 8);
- const message = require('child_process').execSync(`git show -s --format=%s ${sha}`).toString().trim();
- log(` ${i+1}. ${shortSha} - ${message}`);
- });
-
- // Check if we can resume from a previous run
- const startIndexResult = await checkResumeAndPrompt({
- outDir,
- framesPattern,
- commits
- });
-
- // If startIndexResult is -1, all commits have already been processed
- if (startIndexResult === -1) {
- await handleAllCommitsProcessed(outDir, framesPattern, width, height, config.fps, fullscreen);
- return;
- }
-
- // Otherwise, use the returned start index
- const startIndex = startIndexResult;
-
- // Show commit count and confirm
- pretty(`Found ${commits.length} commits to process.`, 'info');
- await confirmLargeCommitCount(commits);
-
- // Save current branch to return to it later (read-only)
- log("Saving current branch name for restoration");
- originalBranch = getCurrentBranch();
-
- // Setup safety exit handler
- setupSafetyExitHandler(originalBranch);
-
- // Process the commits
- await processCommits(commits, startIndex, framesPattern, outDir, serveCmd, url, route, port, waitBeforeMs, waitAfterMs, width, height, density, fullscreen);
-
- // Build video from the captured frames
- const outputVideoPath = await generateTimeLapseVideo(outDir, framesPattern, width, height, config.fps, fullscreen, config.density);
-
- // Restore original dependencies
- await cleanupEnvironment();
-
- // Report the number of frames
- reportProcessedFrames(outDir, framesPattern, commits.length);
-
- if (outputVideoPath) {
- // Pretty completion message with ANSI colors
- console.log('\x1b[32m%s\x1b[0m', `✅ Done! Video saved at: ${outputVideoPath}`);
-
- // Open the video in Finder (macOS only)
- try {
- console.log('\x1b[36mOpening video in Finder...\x1b[0m');
- execSync(`open -R "${outputVideoPath}"`);
- } catch (error) {
- console.log('\x1b[33mCould not open video in Finder automatically.\x1b[0m');
- }
-
- // Show cleanup instructions
- console.log('\n\x1b[36mTo clean up all generated files:\x1b[0m');
- console.log(` rm -rf ${outDir}`);
-
- // Show relative path for easier reference
- const relativeOutDir = path.relative(process.cwd(), outDir);
- if (relativeOutDir !== outDir) {
- console.log(` or: rm -rf ${relativeOutDir}`);
- }
- } else {
- console.log('\x1b[31m%s\x1b[0m', '❌ Failed to generate video.');
- }
-}
-
-/**
- * Handle the case where all commits have already been processed
- */
-async function handleAllCommitsProcessed(
- outDir: string,
- framesPattern: string,
- width: number,
- height: number,
- fps: number,
- fullscreen?: boolean
-): Promise {
- // Check if there's already a video file in the output directory
- const existingVideos = fs.readdirSync(outDir)
- .filter(file => file.toLowerCase().endsWith('.mp4') && file.includes('timelapse'));
-
- if (existingVideos.length === 0) {
- // No video exists yet, so generate one from the existing frames
- pretty("No timelapse video found. Generating one from existing frames...", "info");
- const outputVideoPath = await generateTimeLapseVideo(outDir, framesPattern, width, height, fps, true, 2);
- if (outputVideoPath) {
- pretty(`✅ Done! Video saved at: ${outputVideoPath}`, "success");
-
- // Open the video in Finder (macOS only)
- try {
- pretty("Opening video in Finder...", "info");
- execSync(`open -R "${outputVideoPath}"`);
- } catch (error) {
- log(`Could not open video in Finder: ${error}`);
- }
- }
- } else {
- // Video already exists
- pretty(`Existing timelapse video(s) found: ${existingVideos.join(', ')}`, "info");
-
- // Ask if user wants to generate a new video anyway
- const generateNewVideo = await getUserConfirmation("Generate a new video from existing frames?");
- if (generateNewVideo) {
- const outputVideoPath = await generateTimeLapseVideo(outDir, framesPattern, width, height, fps, true, 2);
- if (outputVideoPath) {
- pretty(`✅ Done! New video saved at: ${outputVideoPath}`, "success");
-
- // Open the video in Finder (macOS only)
- try {
- pretty("Opening video in Finder...", "info");
- execSync(`open -R "${outputVideoPath}"`);
- } catch (error) {
- log(`Could not open video in Finder: ${error}`);
- }
- }
- }
- }
-}
-
-/**
- * Confirm with the user if there are many commits to process
- */
-async function confirmLargeCommitCount(commits: string[]): Promise {
- // User confirmation for large commit counts
- if (commits.length > 10) {
- log(`Asking confirmation for processing ${commits.length} commits`);
-
- // Use gum format for the warning
- pretty(`Processing ${commits.length} commits may take a while.`, 'warning');
-
- try {
- // Use gum confirm for interactive input
- const confirm = await getUserConfirmation(`${commits.length} commits found. Continue?`);
- log(`User confirmation result: ${confirm}`);
-
- if (!confirm) {
- pretty("Operation cancelled by user.", 'error');
- process.exit(0);
- }
-
- // Add a separator line after confirmation
- console.log('');
- } catch (confirmError) {
- // If there's an error with the confirmation, log it and continue anyway
- log(`Error getting user confirmation: ${confirmError instanceof Error ? confirmError.message : String(confirmError)}`);
- console.log('Continuing by default...');
- }
- }
-}
-
-/**
- * Process each commit in the list
- */
-async function processCommits(
- commits: string[],
- startIndex: number,
- framesPattern: string,
- outDir: string,
- serveCmd: string,
- url: string,
- route: string,
- port: number,
- waitBeforeMs: number,
- waitAfterMs: number,
- width: number,
- height: number,
- density: number,
- fullscreen: boolean
-): Promise {
- // Launch browser
- log("Launching puppeteer browser");
- const browser = await puppeteer.launch();
- log("Browser launched successfully");
- log("Creating new page");
- const page = await browser.newPage();
- log("Setting viewport dimensions");
- await page.setViewport({
- width,
- height,
- deviceScaleFactor: density || 1 // Apply deviceScaleFactor based on density
- });
- log(`Viewport set to ${width}x${height} with deviceScaleFactor ${density || 1}`);
-
- try {
- // Initial checkout and setup
- let dependencyState = "";
- if (commits.length > 0) {
- // If we're resuming, checkout the commit at the resume point
- // Otherwise, checkout the first commit
- const commitToCheckout = startIndex > 0 ? commits[startIndex] : commits[0];
-
- if (commitToCheckout) {
- // Clean Gemfile.lock before checkout to prevent conflicts
- cleanGemfileLock();
-
- log(`Checking out initial commit: ${commitToCheckout.substring(0, 8)}`);
- try {
- require('child_process').execSync(`git checkout ${commitToCheckout} --quiet`);
- log("Initial commit checked out successfully");
- } catch (checkoutError) {
- // If checkout fails, try force checkout
- log(`Initial checkout failed, attempting force checkout: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}`);
- require('child_process').execSync(`git checkout -f ${commitToCheckout} --quiet`);
- log("Initial commit force checked out successfully");
- }
-
- // Store project dependency information for comparison and restoration
- switch (projectType) {
- case "rails":
- // For Rails projects, use Gemfile.lock timestamp as reference
- const gemfileLockPath = path.join(process.cwd(), "Gemfile.lock");
- if (fs.existsSync(gemfileLockPath)) {
- const gemfileLockStats = fs.statSync(gemfileLockPath);
- dependencyState = `rails:${gemfileLockStats.mtimeMs}`;
- } else {
- dependencyState = "rails:0";
- }
- break;
-
- case "js":
- // For JS projects, use package.json
- const jsPkgPath = path.join(process.cwd(), "package.json");
- const pkgContent = fs.readFileSync(jsPkgPath, "utf8");
- dependencyState = pkgContent;
- break;
-
- case "hybrid":
- // For hybrid projects, store both
- const hybridPkgPath = path.join(process.cwd(), "package.json");
- const hybridPkgContent = fs.readFileSync(hybridPkgPath, "utf8");
- const hybridGemfileLockPath = path.join(process.cwd(), "Gemfile.lock");
- let gemfileLockTime = "0";
-
- if (fs.existsSync(hybridGemfileLockPath)) {
- const gemStats = fs.statSync(hybridGemfileLockPath);
- gemfileLockTime = String(gemStats.mtimeMs);
- }
-
- dependencyState = `hybrid:${gemfileLockTime}:${hybridPkgContent}`;
- break;
- }
-
- // Detect and install dependencies for this commit
- const packageManager = detectPackageManager(serveCmd);
-
- // Install dependencies directly in the project
- pretty(`📦 Installing dependencies using ${packageManager}...`, "info");
- await installDependencies(packageManager);
-
- log("Dependencies installed without modifying project files");
- } else {
- log("Warning: Initial commit is undefined, skipping initial checkout");
- }
- }
-
- // Variable to track server instance
- let server: any = null;
-
- try {
- // Process each commit, starting from the resume point if applicable
- for (let i = startIndex; i < commits.length; i++) {
- const result = await processCommit(
- i, commits, startIndex, dependencyState, serveCmd,
- server, url, route, waitBeforeMs, waitAfterMs, page, framesPattern,
- density, fullscreen
- );
- server = result.server;
- dependencyState = result.dependencyState;
- }
- } finally {
- // Ensure server is always killed, even if screenshot fails
- log("Frame creation complete, cleaning up resources");
- if (server) {
- await stopServer(server);
- server = null;
- }
- }
- } finally {
- // Clean up browser
- log("Closing puppeteer browser");
- await browser.close();
- log("Browser closed successfully");
-
- // Return to original branch using helper functions
- await restoreRepositoryState(originalBranch || "");
- }
-}
-
-/**
- * Process a single commit
- */
-async function processCommit(
- i: number,
- commits: string[],
- startIndex: number,
- dependencyStateRef: string,
- serveCmd: string,
- serverRef: any,
- url: string,
- route: string,
- waitBeforeMs: number,
- waitAfterMs: number,
- page: any,
- framesPattern: string,
- density?: number,
- fullscreen?: boolean
-): Promise<{server: any, dependencyState: string}> {
- let server = serverRef;
- let dependencyState = dependencyStateRef;
- const sha = commits[i];
-
- // Skip undefined or empty SHA values
- if (!sha) {
- log(`Skipping undefined commit at index ${i}`);
- return {server, dependencyState};
- }
-
- log(`Processing commit ${i+1}/${commits.length}: ${sha.substring(0, 8)}`);
-
- // Get commit date and message
- log("Getting commit metadata");
- const date = require('child_process').execSync(`git show -s --format=%ci ${sha}`).toString().trim();
- const message = require('child_process').execSync(`git show -s --format=%s ${sha}`).toString().trim();
- log(`Commit date: ${date}, message: ${message}`);
-
- // Format and print commit status
- const formattedDate = new Date(date).toLocaleDateString();
- const truncatedMessage = message.substring(0, 60) + (message.length > 60 ? '...' : '');
-
- // Use formatted progress indicator
- console.log('\x1b[35m[%d/%d]\x1b[33m %s\x1b[0m - \x1b[36m%s\x1b[0m',
- i+1, commits.length, formattedDate, truncatedMessage);
-
- // Skip checkout for the first iteration if we're starting from the beginning
- // or if we've already checked out the correct commit during resumption
- if (!(i === 0 && startIndex === 0) && !(i === startIndex && startIndex > 0)) {
- // Reset any changes to Gemfile.lock before checkout to prevent conflicts
- cleanGemfileLock();
-
- log(`Checking out commit: ${sha.substring(0, 8)}`);
- try {
- require('child_process').execSync(`git checkout ${sha} --quiet`);
- log("Checkout complete");
- } catch (checkoutError) {
- // If checkout fails, try force checkout with -f flag
- log(`Checkout failed, attempting force checkout: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}`);
- require('child_process').execSync(`git checkout -f ${sha} --quiet`);
- log("Force checkout complete");
- }
- }
-
- // Check if dependencies have changed from previous commit
- const { dependenciesChanged, newDependencyState } = await checkPackageJsonChanges(dependencyState);
-
- // Update the reference for next comparison
- if (dependenciesChanged && newDependencyState) {
- dependencyState = newDependencyState;
-
- // Handle dependency installation if needed
- const packageManager = detectPackageManager(serveCmd);
- pretty(`📦 Dependencies changed, reinstalling using ${packageManager}...`, "info");
- await installDependencies(packageManager);
- }
-
- // Server reuse logic - only restart if dependencies changed or no server is running
- if (dependenciesChanged || !server) {
- // Stop existing server if running
- if (server) {
- await stopServer(server);
- server = null;
- }
-
- // Start server for this commit
- try {
- pretty(`🚀 Starting server for commit ${i+1}/${commits.length}`, "info");
-
- // Set DEBUG temporarily to see server output during startup
- const originalDebug = process.env.DEBUG;
- process.env.DEBUG = "true";
-
- try {
- server = await startServer({
- serveCmd,
- port: url.includes(':') ? parseInt(url.split(':')[2] || '3000') : 3000,
- waitBeforeMs
- });
- } finally {
- // Restore original DEBUG setting
- process.env.DEBUG = originalDebug;
- }
- } catch (serverStartError) {
- const errorMsg = serverStartError instanceof Error ?
- serverStartError.message : String(serverStartError);
-
- pretty(`⚠️ Commit ${i+1}/${commits.length}: ${sha.substring(0, 8)} - ${message}`, "warning");
-
- // Format the error message for better readability
- if (errorMsg.includes('dependency') || errorMsg.includes('not found')) {
- // Extract the dependency name if possible, otherwise show the full error
- const depPart = errorMsg.includes('dependency') ? errorMsg.split('dependency')[1] : null;
- pretty(` No screenshot saved - Missing dependency: ${depPart?.trim() || errorMsg}`, "warning");
- } else if (errorMsg.length > 5) { // Make sure we have a meaningful error
- pretty(` No screenshot saved - ${errorMsg}`, "warning");
- } else {
- pretty(` No screenshot saved - Server start failed (check server configuration)`, "warning");
- }
-
- return {server, dependencyState}; // Skip to next commit if server fails to start
- }
- } else {
- // Using existing server (reuse)
- log(`Reusing server for commit ${i+1}/${commits.length}`);
- }
-
- // Capture screenshot
- log(`Preparing to take screenshot at route: ${route}`);
- try {
- // Attempt to navigate to the page and check for errors
- const response = await navigateWithRetry(page, url + route, waitBeforeMs);
-
- if (!response) {
- // Navigation failed silently
- pretty(`⚠️ Commit ${i+1}/${commits.length}: ${sha.substring(0, 8)} - ${message}`, "warning");
- pretty(` No screenshot saved - Navigation failed silently`, "warning");
- return {server, dependencyState};
- }
-
- // Check for HTTP error status codes
- if (!await isResponseSuccessful(page, response, i, commits.length, sha, message)) {
- return {server, dependencyState};
- }
-
- // Check for client-side error pages
- if (await isErrorPage(page)) {
- const errorMessage = await extractErrorMessage(page);
- pretty(`⚠️ Commit ${i+1}/${commits.length}: ${sha.substring(0, 8)} - ${message}`, "warning");
- pretty(` No screenshot saved - ${errorMessage}`, "warning");
- return {server, dependencyState};
- }
-
- // Check for empty pages
- if (await isEmptyPage(page)) {
- pretty(`⚠️ Commit ${i+1}/${commits.length}: ${sha.substring(0, 8)} - ${message}`, "warning");
- pretty(` No screenshot saved - Empty or loading page`, "warning");
- return {server, dependencyState};
- }
-
- // Wait additional time after page load if specified
- if (waitAfterMs > 0) {
- log(`Waiting ${waitAfterMs}ms after page load before taking screenshot`);
- await new Promise(resolve => setTimeout(resolve, waitAfterMs));
- }
-
- // Save the screenshot
- await saveScreenshot(page, i, commits.length, message, framesPattern, density, fullscreen);
- } catch (navError) {
- handleNavigationError(navError, i, commits.length, sha, message);
- }
-
- return {server, dependencyState};
-}
-
-/**
- * Report the number of frames that were processed
- */
-function reportProcessedFrames(outDir: string, framesPattern: string | undefined, totalCommits: number): void {
- try {
- const frameFiles = findCapturedFrames(outDir, framesPattern || '');
- const processedCount = frameFiles.length;
- pretty(`\nCreated ${processedCount} frames out of ${totalCommits} commits`, "info");
- } catch {
- // Ignore errors in summary calculation
- }
-}
-
-// Only invoke CLI when run as the main module
-if (import.meta.main) {
- main();
-}
\ No newline at end of file
diff --git a/src/logger.ts b/src/logger.ts
deleted file mode 100644
index d23a53e..0000000
--- a/src/logger.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * Log levels for the application
- */
-export type LogLevel = 'info' | 'success' | 'warning' | 'error';
-
-/**
- * Simple logging with no timestamps - silent by default
- * @param message - The message to log
- */
-export function log(message: string): void {
- // Only print in verbose mode for debug purposes
- if (process.env.DEBUG) {
- console.log(message);
- }
-}
-
-/**
- * Pretty printing with console colors
- * @param message - The message to print
- * @param type - The type of message (info, success, warning, error)
- */
-export function pretty(message: string, type: LogLevel = 'info'): void {
- switch (type) {
- case 'success':
- console.log('\x1b[32m%s\x1b[0m', message); // Green
- break;
- case 'warning':
- console.log('\x1b[33m%s\x1b[0m', message); // Yellow
- break;
- case 'error':
- console.log('\x1b[31m%s\x1b[0m', message); // Red
- break;
- case 'info':
- default:
- console.log('\x1b[36m%s\x1b[0m', message); // Cyan
- break;
- }
-}
\ No newline at end of file
diff --git a/src/package-utils.ts b/src/package-utils.ts
deleted file mode 100644
index 8b75471..0000000
--- a/src/package-utils.ts
+++ /dev/null
@@ -1,323 +0,0 @@
-import fs from "fs";
-import path from "path";
-import { execSync } from "child_process";
-import { log, pretty } from "./logger";
-
-/**
- * Detect package manager from project files and command
- */
-export function detectPackageManager(serveCmd: string): string {
- // For Rails-specific commands
- if (serveCmd.includes('bundle') || serveCmd.includes('rails')) {
- return 'bundle';
- }
-
- // Default to bun for JS projects
- let packageManager = 'bun';
-
- // Check for lockfiles to determine the package manager
- if (fs.existsSync(path.join(process.cwd(), 'yarn.lock'))) {
- packageManager = 'yarn';
- } else if (fs.existsSync(path.join(process.cwd(), 'package-lock.json'))) {
- packageManager = 'npm';
- } else if (fs.existsSync(path.join(process.cwd(), 'pnpm-lock.yaml'))) {
- packageManager = 'pnpm';
- } else if (fs.existsSync(path.join(process.cwd(), 'bun.lock'))) {
- packageManager = 'bun';
- }
-
- // Also check the serve command to further confirm package manager
- if (serveCmd.startsWith('npm ')) {
- packageManager = 'npm';
- } else if (serveCmd.startsWith('yarn ')) {
- packageManager = 'yarn';
- } else if (serveCmd.startsWith('pnpm ')) {
- packageManager = 'pnpm';
- }
-
- return packageManager;
-}
-
-/**
- * Get install command with frozen lockfile for given package manager
- */
-export function getInstallCommand(packageManager: string): string {
- switch (packageManager) {
- case 'bundle':
- return 'bundle install';
- case 'yarn':
- return 'yarn install --frozen-lockfile';
- case 'npm':
- return 'npm ci';
- case 'pnpm':
- return 'pnpm install --frozen-lockfile';
- case 'bun':
- default:
- return 'bun install --no-save --exact';
- }
-}
-
-/**
- * Get standard install command (not the --frozen-lockfile version)
- */
-export function getRegularInstallCommand(packageManager: string): string {
- switch (packageManager) {
- case 'bundle':
- return 'bundle install';
- case 'yarn':
- return 'yarn install';
- case 'npm':
- return 'npm install';
- case 'pnpm':
- return 'pnpm install';
- case 'bun':
- default:
- return 'bun install';
- }
-}
-
-/**
- * Install dependencies using specified package manager
- */
-export async function installDependencies(packageManager: string): Promise {
- const pm = packageManager;
- const cmd = getInstallCommand(pm);
- try {
- log(`Running: ${cmd}`);
- execSync(cmd, { stdio: 'inherit', timeout: 120000 });
- pretty(`✅ Dependencies installed successfully`, "success");
- } catch (error) {
- const msg = error instanceof Error ? error.message : String(error);
- pretty(`❌ Dependency installation failed: ${msg}`, "error");
- // Continue anyway
- }
-}
-
-/**
- * Check if dependencies have changed and extract dependency changes
- */
-export async function checkPackageJsonChanges(dependencyState: string): Promise<{
- dependenciesChanged: boolean;
- newDependencyState?: string
-}> {
- try {
- // Detect project type from the stored format
- if (dependencyState && dependencyState.startsWith('rails:')) {
- return handleRailsProjectChanges(dependencyState);
- } else if (dependencyState && dependencyState.startsWith('hybrid:')) {
- return handleHybridProjectChanges(dependencyState);
- } else {
- return handleJsProjectChanges(dependencyState);
- }
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : String(error);
- pretty(`❌ Error checking dependencies: ${errorMsg}`, "error");
- return { dependenciesChanged: false };
- }
-}
-
-/**
- * Check for dependency changes in a Rails project
- */
-function handleRailsProjectChanges(dependencyState: string): {
- dependenciesChanged: boolean;
- newDependencyState?: string
-} {
- const gemfileLockPath = path.join(process.cwd(), "Gemfile.lock");
- if (!fs.existsSync(gemfileLockPath)) {
- // If no Gemfile.lock, consider it unchanged
- return { dependenciesChanged: false };
- }
-
- // Extract the previous timestamp
- const prevTimestampStr = dependencyState.split(':')[1] || "0";
- let prevTimestamp = 0;
-
- try {
- prevTimestamp = parseFloat(prevTimestampStr);
- if (isNaN(prevTimestamp)) {
- prevTimestamp = 0;
- }
- } catch (e) {
- prevTimestamp = 0;
- }
-
- // Get current timestamp
- const gemfileLockStats = fs.statSync(gemfileLockPath);
-
- if (gemfileLockStats.mtimeMs > prevTimestamp) {
- log("Gemfile.lock has changed since previous commit");
- return { dependenciesChanged: true, newDependencyState: `rails:${gemfileLockStats.mtimeMs}` };
- } else {
- log("Gemfile.lock unchanged from previous commit");
- return { dependenciesChanged: false, newDependencyState: `rails:${gemfileLockStats.mtimeMs}` };
- }
-}
-
-/**
- * Check for dependency changes in a hybrid project (Rails + JS)
- */
-function handleHybridProjectChanges(dependencyState: string): {
- dependenciesChanged: boolean;
- newDependencyState?: string
-} {
- const parts = dependencyState.split(':');
- if (parts.length < 3) {
- // Invalid format, assume changed
- return { dependenciesChanged: true };
- }
-
- const prevGemfileTimestamp = parseFloat(parts[1] || "0");
- const prevPkgContent = parts.slice(2).join(':');
-
- let gemfileChanged = false;
- let pkgJsonChanged = false;
-
- // Check Gemfile.lock changes
- const gemfileLockPath = path.join(process.cwd(), "Gemfile.lock");
- let currentGemfileTimestamp = 0;
-
- if (fs.existsSync(gemfileLockPath)) {
- const gemfileLockStats = fs.statSync(gemfileLockPath);
- currentGemfileTimestamp = gemfileLockStats.mtimeMs;
-
- if (currentGemfileTimestamp > prevGemfileTimestamp) {
- gemfileChanged = true;
- log("Gemfile.lock has changed since previous commit");
- }
- }
-
- // Check package.json changes
- const pkgPath = path.join(process.cwd(), "package.json");
- let currentPkgContent = "";
-
- if (fs.existsSync(pkgPath)) {
- currentPkgContent = fs.readFileSync(pkgPath, "utf8");
-
- // Compare package.json dependencies
- try {
- const prevPkg = JSON.parse(prevPkgContent || "{}");
- const currentPkg = JSON.parse(currentPkgContent);
-
- const prevDeps = {
- ...(prevPkg.dependencies || {}),
- ...(prevPkg.devDependencies || {})
- };
-
- const currentDeps = {
- ...(currentPkg.dependencies || {}),
- ...(currentPkg.devDependencies || {})
- };
-
- // Compare dependencies specifically
- pkgJsonChanged = JSON.stringify(prevDeps) !== JSON.stringify(currentDeps);
-
- if (pkgJsonChanged) {
- log("package.json dependencies have changed since previous commit");
- }
- } catch (parseError) {
- // If there's a parsing error, assume we need to reinstall
- log(`Error parsing package.json: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
- pkgJsonChanged = true;
- }
- }
-
- // Return true if either dependency source changed
- return {
- dependenciesChanged: gemfileChanged || pkgJsonChanged,
- newDependencyState: `hybrid:${currentGemfileTimestamp}:${currentPkgContent}`
- };
-}
-
-/**
- * Check for dependency changes in a JavaScript project
- */
-function handleJsProjectChanges(dependencyState: string): {
- dependenciesChanged: boolean;
- newDependencyState?: string
-} {
- const pkgPath = path.join(process.cwd(), "package.json");
-
- if (!fs.existsSync(pkgPath)) {
- return { dependenciesChanged: false };
- }
-
- const currentPackageJson = fs.readFileSync(pkgPath, "utf8");
-
- // If the files are identical, no change
- if (currentPackageJson === dependencyState) {
- log("package.json unchanged from previous commit");
- return { dependenciesChanged: false };
- }
-
- // Parse package.json to compare dependencies specifically
- try {
- const prevPkg = JSON.parse(dependencyState || "{}");
- const currentPkg = JSON.parse(currentPackageJson);
-
- const prevDeps = {
- ...(prevPkg.dependencies || {}),
- ...(prevPkg.devDependencies || {})
- };
-
- const currentDeps = {
- ...(currentPkg.dependencies || {}),
- ...(currentPkg.devDependencies || {})
- };
-
- // Compare dependencies specifically
- const depsChanged = JSON.stringify(prevDeps) !== JSON.stringify(currentDeps);
-
- if (depsChanged) {
- log("package.json dependencies have changed since previous commit");
- return { dependenciesChanged: true, newDependencyState: currentPackageJson };
- } else {
- log("package.json content changed but dependencies are the same");
- return { dependenciesChanged: false, newDependencyState: currentPackageJson };
- }
- } catch (parseError) {
- // If there's a parsing error, assume we need to reinstall
- log(`Error parsing package.json: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
- return { dependenciesChanged: true, newDependencyState: currentPackageJson };
- }
-}
-
-/**
- * Clean up environment before exiting
- */
-export async function cleanupEnvironment(): Promise {
- pretty("Restoring original dependencies...", "info");
-
- try {
- // Detect package manager
- const packageManager = detectPackageManager('');
- const installCmd = getRegularInstallCommand(packageManager);
-
- log(`Reinstalling dependencies with: ${installCmd}`);
- execSync(installCmd, { stdio: 'pipe' });
- pretty("✅ Original dependencies restored", "success");
- } catch (restoreError) {
- const errorMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
- pretty(`Warning: Could not restore original dependencies: ${errorMsg}`, "warning");
- pretty("You may need to run 'npm install' or equivalent manually.", "warning");
- }
-
- await cleanupTempFiles();
-}
-
-/**
- * Clean up any temporary files created during execution
- */
-export async function cleanupTempFiles(): Promise {
- // Check for and delete any lock files that might have been created by bun
- const lockFilePath = path.join(process.cwd(), 'bun.lock');
- if (fs.existsSync(lockFilePath) && !fs.existsSync(path.join(process.cwd(), 'bun.lockb'))) {
- // Only remove if it's not a regular bun project that uses bun.lock
- try {
- fs.unlinkSync(lockFilePath);
- log("Removed generated bun.lock file");
- } catch (err) {
- log(`Warning: Could not remove lock file: ${err instanceof Error ? err.message : String(err)}`);
- }
- }
-}
\ No newline at end of file
diff --git a/src/resume-utils.ts b/src/resume-utils.ts
deleted file mode 100644
index a28c1a5..0000000
--- a/src/resume-utils.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import * as fileUtilsModule from "./file-utils";
-import * as loggerModule from "./logger";
-import * as userInteractionModule from "./user-interaction";
-
-export interface ResumeOptions {
- outDir: string;
- framesPattern: string;
- commits: string[];
-}
-
-/**
- * Dependencies required by the resume functionality
- */
-export interface ResumeDependencies {
- fileUtils: {
- findLastProcessedFrame: typeof fileUtilsModule.findLastProcessedFrame;
- getLastCommitInfo: typeof fileUtilsModule.getLastCommitInfo;
- };
- logger: {
- pretty: typeof loggerModule.pretty;
- };
- userInteraction: {
- getUserConfirmation: typeof userInteractionModule.getUserConfirmation;
- };
-}
-
-
-/**
- * Check if a resume is possible and prompt the user for confirmation
- * @param options - Options including output directory, frames pattern, and commit list
- * @param deps - Optional dependencies to inject (useful for testing)
- * @returns The index to start processing from (0 to start from beginning, > 0 to resume)
- */
-export async function checkResumeAndPrompt(
- options: ResumeOptions,
- deps?: ResumeDependencies
-): Promise {
- const { outDir, framesPattern, commits } = options;
- const findLastProcessedFrame = deps?.fileUtils?.findLastProcessedFrame ?? fileUtilsModule.findLastProcessedFrame;
- const getLastCommitInfo = deps?.fileUtils?.getLastCommitInfo ?? fileUtilsModule.getLastCommitInfo;
- const pretty = deps?.logger?.pretty ?? loggerModule.pretty;
- const getUserConfirmation = deps?.userInteraction?.getUserConfirmation ?? userInteractionModule.getUserConfirmation;
-
- // Look for the last processed frame
- const lastFrameNumber = findLastProcessedFrame(outDir, framesPattern);
-
- // If no frames found or invalid frame number, start from beginning
- if (lastFrameNumber < 0) {
- return 0;
- }
-
- // If the last frame number is valid, get info about the last commit
- const lastCommitInfo = getLastCommitInfo(lastFrameNumber, commits);
-
- // If no commit info found, start from beginning
- if (!lastCommitInfo) {
- return 0;
- }
-
- // Show info to the user
- pretty(`Found existing frames in ${outDir}`, "info");
- pretty(`Last processed commit: #${lastFrameNumber + 1} (${lastCommitInfo.shortSha}: ${lastCommitInfo.message})`, "info");
-
- // Ask user if they want to resume
- const resumeConfirm = await getUserConfirmation("Resume from last processed commit?");
-
- if (resumeConfirm) {
- // Start from the next commit after the last processed one
- const startIndex = lastFrameNumber + 1;
- if (startIndex < commits.length) {
- pretty(`Resuming from commit ${startIndex + 1}/${commits.length}`, "success");
- return startIndex;
- } else {
- pretty("All commits have already been processed. Nothing to do.", "info");
- return -1; // Signal that all commits are already processed
- }
- } else {
- // User chose to start over
- pretty("Starting from the beginning (existing frames will be overwritten)", "warning");
- return 0;
- }
-}
\ No newline at end of file
diff --git a/src/screenshot-utils.ts b/src/screenshot-utils.ts
deleted file mode 100644
index 3ca849e..0000000
--- a/src/screenshot-utils.ts
+++ /dev/null
@@ -1,367 +0,0 @@
-import fs from "fs";
-import path from "path";
-import { log, pretty } from "./logger";
-import type { Page, HTTPResponse } from "puppeteer";
-
-/**
- * Navigate to a URL with retry capability
- */
-export async function navigateWithRetry(page: Page, url: string, waitMs: number): Promise {
- log(`Navigating to ${url}`);
- const navigationTimeout = waitMs * 3;
- log(`Using navigation timeout of ${navigationTimeout}ms`);
-
- let retryAttempted = false;
-
- try {
- return await page.goto(url, {
- waitUntil: "networkidle0",
- timeout: navigationTimeout
- });
- } catch (navErr) {
- if (retryAttempted) {
- throw navErr;
- }
-
- // First failure, try once more with a different wait strategy
- log("Navigation failed, retrying with different wait strategy");
-
- const response = await page.goto(url, {
- waitUntil: "domcontentloaded",
- timeout: navigationTimeout
- });
-
- // If we've loaded the DOM but not all resources, wait a bit more
- await new Promise(resolve => setTimeout(resolve, 1000));
- return response;
- }
-}
-
-/**
- * Check if the HTTP response was successful
- */
-export async function isResponseSuccessful(
- page: Page,
- response: HTTPResponse,
- commitIndex: number,
- totalCommits: number,
- sha: string,
- message: string
-): Promise {
- const statusCode = response.status();
- const statusText = response.statusText();
- log(`Page loaded with status code: ${statusCode} (${statusText})`);
-
- if (statusCode < 400) {
- return true;
- }
-
- // Try to get more detailed error information
- let errorDetails = "";
- try {
- const errorContent = await extractHttpErrorDetails(page);
- if (errorContent) {
- errorDetails = ` - ${errorContent.substring(0, 150).replace(/\n/g, ' ')}`;
- }
- } catch (evalError) {
- // Ignore errors from page.evaluate
- }
-
- skipCommitWithWarning(commitIndex, totalCommits, sha, message,
- errorDetails.includes('Missing dependency')
- ? `Dependency error: ${errorDetails.replace(' - ', '')}`
- : `HTTP ${statusCode} ${statusText}${errorDetails}`
- );
-
- return false;
-}
-
-/**
- * Extract error details from HTTP error page
- */
-export async function extractHttpErrorDetails(page: Page): Promise {
- return await page.evaluate(() => {
- // Look for common error containers
- const errorElements = [
- document.querySelector('.error-message, .error-text, .error-info'),
- document.querySelector('.error-details, .stack-trace'),
- document.querySelector('#error-container, .error-container'),
- document.querySelector('[role="alert"]'),
- document.querySelector('pre'),
- document.querySelector('h1, h2'),
- document.body
- ];
-
- // Return the first non-empty error element text
- for (const el of errorElements) {
- if (el && el.textContent && el.textContent.trim()) {
- const text = el.textContent.trim();
- return text.length > 100 ? text.split('\n').slice(0, 2).join(' ') : text;
- }
- }
-
- // Try to extract common error patterns
- const bodyText = document.body.textContent || '';
-
- // Look for dependency errors
- const missingDepMatch = bodyText.match(/dependency ["']([^"']+)["'] not found/i) ||
- bodyText.match(/Cannot find module ["']([^"']+)["']/i) ||
- bodyText.match(/Module not found: Error: Can't resolve ["']([^"']+)["']/i);
-
- if (missingDepMatch && missingDepMatch[1]) {
- return `Missing dependency: ${missingDepMatch[1]}`;
- }
-
- // Look for syntax errors
- const syntaxErrorMatch = bodyText.match(/SyntaxError: ([^\n]+)/i);
- if (syntaxErrorMatch && syntaxErrorMatch[1]) {
- return `Syntax error: ${syntaxErrorMatch[1]}`;
- }
-
- return "";
- });
-}
-
-/**
- * Check if the rendered page is an error page
- */
-export async function isErrorPage(page: Page): Promise {
- const pageTitle = await page.title();
- const pageContent = await page.content();
-
- // Definitive error patterns
- const errorPatterns = [
- '404 Not Found', '500 Internal Server Error', 'Error Page',
- 'Something went wrong', 'page not found', 'cannot display the webpage'
- ];
-
- // Check for explicit error content
- const hasExplicitErrorContent = errorPatterns.some(pattern =>
- pageTitle.toLowerCase().includes(pattern.toLowerCase()) ||
- pageContent.toLowerCase().includes(pattern.toLowerCase())
- );
-
- // Check if the page has error elements
- let isDefiniteErrorPage: boolean;
- try {
- isDefiniteErrorPage = await page.evaluate(() => {
- // Check for HTTP status code elements
- const statusElements = document.querySelectorAll('.status-code, .error-code, code');
- for (const el of Array.from(statusElements)) {
- const text = el.textContent || '';
- if (text.match(/^[45]\d{2}$/) || text.includes('404') || text.includes('500')) {
- return true;
- }
- }
-
- // Check for explicit error message containers
- const errorContainers = document.querySelectorAll(
- '.error-message, .error-container, .alert-danger, .exception, ' +
- '[role="alert"], .error-title, .error-description'
- );
- if (Array.from(errorContainers).length > 0) {
- return true;
- }
-
- // Check title for error indicators
- const title = document.title || '';
- if (
- title.includes('404') ||
- title.includes('500') ||
- title.match(/not found/i) ||
- title.match(/server error/i) ||
- title.match(/^error\b/i)
- ) {
- return true;
- }
-
- // Check for a mostly empty page
- const contentElements = document.body.querySelectorAll('div, p, h1, h2, h3, section, main');
- return Array.from(contentElements).length < 3 && (document.body.textContent?.trim().length || 0) < 50;
- });
- } catch (evalError) {
- // If evaluation fails, assume it's not an error page
- isDefiniteErrorPage = false;
- }
-
- return isDefiniteErrorPage || hasExplicitErrorContent;
-}
-
-/**
- * Extract error message from error page
- */
-export async function extractErrorMessage(page: Page): Promise {
- try {
- const extractedError = await page.evaluate(() => {
- // Common error message container selectors
- const errorSelectors = [
- '.error-message', '.alert-danger', '.error-details',
- '#error-container', '[role="alert"]', '.exception-message',
- 'title', 'h1', '.main-error', '.error-code', '.status-code'
- ];
-
- for (const selector of errorSelectors) {
- const el = document.querySelector(selector);
- if (el && el.textContent) {
- const content = el.textContent.trim();
- if (content) {
- return content;
- }
- }
- }
-
- // Try the body text
- const bodyText = document.body.textContent || "";
- if (bodyText.length < 100) {
- return bodyText.trim();
- }
-
- // Look for error messages
- const errorRegex = /error:?\s+([^\n.]+)/i;
- const match = bodyText.match(errorRegex);
- if (match && match[1]) {
- return match[1].trim();
- }
-
- return "";
- });
-
- if (extractedError) {
- return extractedError.substring(0, 100).replace(/\n/g, ' ');
- }
-
- return "Empty or error page detected";
- } catch (evalError) {
- return "Error page (could not extract details)";
- }
-}
-
-/**
- * Check if the page is empty or has minimal content
- */
-export async function isEmptyPage(page: Page): Promise {
- try {
- return await page.evaluate(() => {
- const bodyText = document.body.textContent || "";
- const trimmedText = bodyText.trim();
-
- // Check if the page has almost no content
- if (trimmedText.length < 10) {
- return true;
- }
-
- // Check for visible elements
- const allElements = Array.from(document.querySelectorAll('*'));
- const visibleElements = allElements.filter(el => {
- const style = window.getComputedStyle(el);
- return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
- });
-
- // If very few visible elements with content, might be empty/loading
- return visibleElements.length < 5 && trimmedText.length < 30;
- });
- } catch (evalError) {
- // If evaluation fails, assume it's not empty
- return false;
- }
-}
-
-/**
- * Save a screenshot from the current page
- */
-export async function saveScreenshot(
- page: Page,
- commitIndex: number,
- totalCommits: number,
- message: string,
- framesPattern: string,
- density?: number,
- fullscreen?: boolean
-): Promise {
- // Create filename from commit message
- const commitWords = message.split(' ');
- const truncatedMessage = commitWords.slice(0, 5).join('_').replace(/[^a-zA-Z0-9_-]/g, '');
- const frame = `${framesPattern}${String(commitIndex).padStart(3, "0")}_${truncatedMessage}.png`;
-
- log(`Saving screenshot to: ${frame}`);
-
- await page.screenshot({
- path: frame,
- fullPage: fullscreen === true
- });
-
- // Verify the screenshot was created
- if (!fs.existsSync(frame)) {
- pretty(`❌ Failed to create screenshot at ${frame}`, "error");
- process.exit(1);
- }
-
- // Success message
- pretty(`✅ [${commitIndex+1}/${totalCommits}] Screenshot saved`, "success");
-}
-
-/**
- * Display warning for skipped commit
- */
-export function skipCommitWithWarning(
- commitIndex: number,
- totalCommits: number,
- sha: string,
- message: string | undefined,
- reason: string | undefined
-): void {
- const safeMessage = message || '(no message)';
- const safeReason = reason || 'Unknown error';
- pretty(`⚠️ Commit ${commitIndex+1}/${totalCommits}: ${sha.substring(0, 8)} - ${safeMessage}`, "warning");
- pretty(` No screenshot saved - ${safeReason}`, "warning");
-}
-
-/**
- * Handle navigation errors
- */
-export function handleNavigationError(
- error: any,
- commitIndex: number,
- totalCommits: number,
- sha: string,
- message: string
-): void {
- const errorMessage = error instanceof Error ? error.message : String(error);
- const shortErrorMessage = errorMessage.split('\n')[0];
-
- // Identify connection errors
- const isConnectionError = errorMessage.includes('ERR_CONNECTION') ||
- errorMessage.includes('ECONNREFUSED') ||
- errorMessage.includes('ETIMEDOUT');
-
- // Check for any navigation-related errors
- const isNavigationError = isConnectionError ||
- errorMessage.includes('ERR_ABORTED') ||
- errorMessage.includes('ERR_FAILED') ||
- errorMessage.includes('ERR_NETWORK') ||
- errorMessage.includes('navigation');
-
- if (isNavigationError) {
- // Extract specific error type
- let issue = shortErrorMessage;
-
- if (errorMessage.includes('ECONNREFUSED')) {
- issue = "Connection refused - server may not have started";
- } else if (errorMessage.includes('ETIMEDOUT')) {
- issue = "Connection timed out - server may be slow to respond";
- } else if (errorMessage.includes('ERR_CONNECTION_RESET')) {
- issue = "Connection reset - server closed the connection";
- } else if (errorMessage.includes('ERR_EMPTY_RESPONSE')) {
- issue = "Empty response - server didn't return any data";
- } else if (errorMessage.includes('ERR_ABORTED')) {
- issue = "Navigation aborted - page may be redirecting or reloading";
- } else if (errorMessage.includes('ERR_FAILED')) {
- issue = "Navigation failed - page may be unreachable";
- }
-
- skipCommitWithWarning(commitIndex, totalCommits, sha, message, issue);
- } else {
- // For other errors
- skipCommitWithWarning(commitIndex, totalCommits, sha, message, `Unexpected error: ${shortErrorMessage}`);
- }
-}
\ No newline at end of file
diff --git a/src/server-utils.ts b/src/server-utils.ts
deleted file mode 100644
index 76d9b53..0000000
--- a/src/server-utils.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import { spawn } from "child_process";
-import { log, pretty } from "./logger";
-import { existsSync, unlinkSync } from "fs";
-import { join, resolve } from "path";
-import { execSync } from "child_process";
-
-interface ServerOptions {
- serveCmd: string;
- port: number;
- waitBeforeMs: number;
-}
-
-/**
- * Prepare server command with appropriate port settings
- */
-export function prepareServerCommand(serveCmd: string, port: number) {
- log("Preparing server command");
- const cmdParts = serveCmd.split(" ");
- const cmd = cmdParts[0] || "bun";
- const parts = cmdParts.slice(1);
-
- // Use the original command as a starting point
- let modifiedCmd = cmd;
- let modifiedParts = [...parts];
-
- // For Rails server command
- if (serveCmd.includes('rails server') || serveCmd.includes('rails s')) {
- log("Detected Rails server command");
-
- // Always add explicit port
- modifiedParts = modifiedParts.filter(part => !part.startsWith('-p') && part !== '--port');
- modifiedParts.push(`-p`);
- modifiedParts.push(`${port}`);
- log(`Added explicit port ${port} to Rails server command`);
-
- return { cmd: modifiedCmd, args: modifiedParts };
- }
-
- // For dev servers, add port flag if not present
- if (serveCmd.includes('dev') || serveCmd.includes('start') || serveCmd.includes('serve')) {
- log("Detected dev server command");
-
- if (!serveCmd.includes('--port') && !serveCmd.includes('-p')) {
- if (cmd === 'npm') {
- // For npm, we need to pass args differently
- modifiedParts.push('--');
- modifiedParts.push(`--port=${port}`);
- } else {
- // For other package managers
- modifiedParts.push(`--port=${port}`);
- }
- log(`Added explicit port ${port} to server command`);
- }
- }
-
- return { cmd: modifiedCmd, args: modifiedParts };
-}
-
-/**
- * Start the development server
- */
-export async function startServer({ serveCmd, port, waitBeforeMs }: ServerOptions): Promise {
- log("Preparing to start server");
-
- // Parse the serve command
- const { cmd, args } = prepareServerCommand(serveCmd, port);
- log(`Spawning server process: ${cmd} ${args.join(" ")}`);
-
- try {
- // Spawn the server process, detached so it runs in the background
- const server = spawn(cmd, args, {
- stdio: ['ignore', 'pipe', 'pipe'],
- env: { ...process.env, FORCE_COLOR: 'true' },
- detached: true
- });
-
- if (!server || !server.pid) {
- throw new Error("Failed to spawn server process - no process ID");
- }
-
- // Setup output handlers and get a function to retrieve error details
- const getErrorDetails = setupOutputHandlers(server);
-
- // Create promises for server status detection
- const earlyExitPromise = createEarlyExitDetector(server, getErrorDetails);
- const serverReadyPromise = createServerReadyDetector(server, waitBeforeMs, getErrorDetails);
-
- // Wait for server to start up or fail
- log(`Waiting ${waitBeforeMs}ms for server to start`);
-
- // Wait for the server to be ready or fail early
- const result = await Promise.race([serverReadyPromise, earlyExitPromise]);
-
- // serverReadyPromise returns true if ready, earlyExitPromise throws on error
- if (result === false) {
- throw new Error(getErrorDetails() || "Server startup failed silently");
- }
-
- // Final error check
- const errorDetails = getErrorDetails();
- if (errorDetails) {
- throw new Error(errorDetails);
- }
-
- log("Server is ready");
- return server;
- } catch (serverError) {
- const errorMsg = serverError instanceof Error ? serverError.message : String(serverError);
- log(`Server start failed: ${errorMsg}`);
-
- // Ensure we have a meaningful error message
- if (!errorMsg || errorMsg === "error when starting dev server:") {
- throw new Error("Failed to start server - check dependencies and server configuration");
- } else {
- throw new Error(`${errorMsg}`);
- }
- }
-}
-
-/**
- * Stop the server
- */
-export async function stopServer(server: any): Promise {
- log("Stopping server");
- if (!server || typeof server.kill !== 'function') {
- log("Warning: Server object not valid or missing kill function");
- return;
- }
-
- try {
- // For Rails specifically, attempt to remove PID file first
- await cleanupRailsPidFile();
-
- // Kill entire process group: first SIGTERM, then SIGKILL to ensure shutdown
- server.kill('SIGTERM');
- try {
- process.kill(-server.pid, 'SIGKILL');
- } catch {
- // Ignore errors, as process may already be terminated
- }
-
- log("Server process killed");
- } catch (killError) {
- log(`Error killing server: ${killError instanceof Error ? killError.message : String(killError)}`);
- }
-}
-
-/**
- * Create a promise that rejects when the server exits unexpectedly
- */
-function createEarlyExitDetector(server: any, errorDetailsGetter: () => string) {
- return new Promise((_, reject) => {
- server.on('exit', (code: number | null, signal: string | null) => {
- if (code !== null || signal !== null) {
- log(`Server exited early with ${code !== null ? `code ${code}` : `signal ${signal}`}`);
- const error = errorDetailsGetter() ||
- `Server exited unexpectedly with ${code !== null ? `code ${code}` : `signal ${signal}`}`;
- reject(new Error(error));
- }
- });
- });
-}
-
-/**
- * Create a promise that resolves when the server appears ready
- */
-function createServerReadyDetector(server: any, waitMs: number, errorDetailsGetter: () => string) {
- return new Promise(resolve => {
- let isReady = false;
-
- // Check stdout for ready indicators
- const stdoutListener = (data: Buffer) => {
- const output = data.toString();
- // Look for common "ready" messages in server output
- if (output.includes('ready') ||
- output.includes('listening') ||
- output.includes('started') ||
- output.includes('running') ||
- output.includes('localhost') ||
- // Rails-specific ready messages
- output.includes('Listening on') ||
- output.includes('Use Ctrl-C to stop') ||
- output.includes('Puma starting') ||
- output.includes('WEBrick::HTTPServer#start')) {
- isReady = true;
- resolve(true);
- }
- };
-
- if (server.stdout) {
- server.stdout.on('data', stdoutListener);
- }
-
- // Also set a timeout to resolve anyway if we don't see ready message
- setTimeout(() => {
- const errorDetails = errorDetailsGetter();
- if (!isReady && !errorDetails) {
- resolve(true);
- } else if (!isReady && errorDetails) {
- resolve(false);
- }
- }, waitMs);
- });
-}
-
-/**
- * Process server output to detect errors
- */
-function setupOutputHandlers(server: any) {
- let serverOutputBuffer = "";
- let serverErrorBuffer = "";
- let errorDetails = "";
-
- // Configure error handling
- server.on('error', (err: Error) => {
- const errMsg = err?.message || String(err);
- log(`Server process error: ${errMsg}`);
- errorDetails = `Process error: ${errMsg}`;
- });
-
- // Capture stdout
- if (server.stdout) {
- server.stdout.on('data', (data: Buffer) => {
- const output = data.toString();
- serverOutputBuffer += output;
-
- // Log output for debugging
- output.split('\n').filter(Boolean).forEach((line: string) => {
- log(`Server stdout: ${line.trim()}`);
- });
-
- // Look for error indicators
- if ((output.includes('Error') || output.includes('error')) &&
- !output.includes('compiled') &&
- !output.includes('successfully')) {
- errorDetails = output.split('\n')
- .find((line: string) => line.includes('Error') || line.includes('error'))
- ?.trim() || output.trim();
- }
-
- // Look for dependency issues
- if (output.includes('not found') || output.includes('missing')) {
- const match = output.match(/['"]([^'"]+)['"] not found/) ||
- output.match(/missing ([^'"]+)/i);
- if (match && match[1]) {
- errorDetails = `Missing dependency: ${match[1]}`;
- }
- }
- });
- }
-
- // Capture stderr
- if (server.stderr) {
- server.stderr.on('data', (data: Buffer) => {
- const output = data.toString();
- serverErrorBuffer += output;
-
- // Log error output for debugging
- output.split('\n').filter(Boolean).forEach((line: string) => {
- log(`Server stderr: ${line.trim()}`);
- });
-
- // Capture error details
- if (!errorDetails && (
- output.includes('Error') ||
- output.includes('error') ||
- output.includes('not found')
- )) {
- errorDetails = output.trim().split('\n')[0] || '';
- }
- });
- }
-
- return () => errorDetails;
-}
-
-/**
- * Cleans up Rails PID files to prevent "server already running" errors
- */
-async function cleanupRailsPidFile(): Promise {
- try {
- // Delete the main Rails PID file
- const pidPath = "tmp/pids/server.pid";
- const fullPath = resolve(process.cwd(), pidPath);
-
- if (existsSync(fullPath)) {
- log(`Found Rails PID file at ${fullPath}, removing it`);
- unlinkSync(fullPath);
- }
- } catch (error) {
- log(`Warning: Error cleaning up Rails PID file: ${error instanceof Error ? error.message : String(error)}`);
- // Non-fatal error - continue with server termination
- }
-}
\ No newline at end of file
diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts
deleted file mode 100644
index 931b975..0000000
--- a/src/tests/cli.test.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import { describe, test, expect } from "bun:test";
-import { parseArgs, detectServeCommand } from "../cli";
-import path from "path";
-
-describe("parseArgs", () => {
- test("should use default values when no args provided", () => {
- const cwd = "/test/dir";
- const config = parseArgs([], cwd);
-
- expect(config.outDir).toBe(path.join(cwd, "timelapse"));
- expect(config.width).toBe(1440);
- expect(config.height).toBe(720);
- expect(config.waitBeforeMs).toBe(3000);
- expect(config.waitAfterMs).toBe(0);
- expect(config.route).toBe("/");
- expect(config.port).toBe(3000);
- expect(config.branch).toBeUndefined();
- expect(config.maxCommits).toBeUndefined();
- expect(config.fps).toBe(12);
- expect(config.density).toBe(2); // Default density is 2x (high resolution)
- expect(config.fullscreen).toBe(false); // Default fullscreen is false
- });
-
- test("parses width and height", () => {
- const config = parseArgs(["-w", "800", "--height", "600"]);
-
- expect(config.width).toBe(800);
- expect(config.height).toBe(600);
- });
-
- test("parses outDir correctly", () => {
- const cwd = "/test/dir";
- const config = parseArgs(["-o", "output"], cwd);
-
- expect(config.outDir).toBe(path.join(cwd, "output"));
- });
-
- test("parses route with leading slash", () => {
- const config = parseArgs(["-r", "test"]);
-
- expect(config.route).toBe("/test");
- });
-
- test("maintains leading slash in route", () => {
- const config = parseArgs(["-r", "/test"]);
-
- expect(config.route).toBe("/test");
- });
-
- test("parses port", () => {
- const config = parseArgs(["-p", "8080"]);
-
- expect(config.port).toBe(8080);
- });
-
- test("parses branch", () => {
- const config = parseArgs(["--branch", "develop"]);
-
- expect(config.branch).toBe("develop");
- });
-
- test("parses maxCommits", () => {
- const config = parseArgs(["--max-commits", "50"]);
-
- expect(config.maxCommits).toBe(50);
- });
-
- test("parses fps", () => {
- const config = parseArgs(["--fps", "30"]);
-
- expect(config.fps).toBe(30);
- });
-
- test("parses density", () => {
- const config = parseArgs(["--density", "3"]);
-
- expect(config.density).toBe(3);
- });
-
- test("enables fullscreen mode", () => {
- const config = parseArgs(["--fullscreen"]);
-
- expect(config.fullscreen).toBe(true);
- });
-
- test("parses wait-before time", () => {
- const config = parseArgs(["--wait-before", "5000"]);
-
- expect(config.waitBeforeMs).toBe(5000);
- });
-
- test("parses wait-after time", () => {
- const config = parseArgs(["--wait-after", "2000"]);
-
- expect(config.waitAfterMs).toBe(2000);
- });
-
- test("throws on unknown argument", () => {
- expect(() => parseArgs(["--unknown"])).toThrow("Unknown argument: --unknown");
- });
-});
-
-describe("detectServeCommand", () => {
- test("should prefer dev script when available", () => {
- const scripts = {
- dev: "vite dev",
- start: "node server.js"
- };
-
- expect(detectServeCommand(scripts)).toBe("bun run dev");
- });
-
- test("should use start script when dev is not available", () => {
- const scripts = {
- start: "next start",
- build: "next build"
- };
-
- expect(detectServeCommand(scripts)).toBe("bun run start");
- });
-
- test("should throw when neither dev nor start scripts are available", () => {
- const scripts = {
- build: "next build",
- lint: "eslint ."
- };
-
- expect(() => detectServeCommand(scripts)).toThrow("No 'dev' or 'start' script found in package.json.");
- });
-});
-
-describe("parseArgs error cases", () => {
- const cwd = "/test";
- const flags = [
- ["-o"], ["--out-dir"],
- ["-w"], ["--width"],
- ["-h"], ["--height"],
- ["--wait-before"], ["--wait-after"],
- ["-r"], ["--route"],
- ["-p"], ["--port"],
- ["--branch"],
- ["--max-commits"],
- ["--fps"]
- ];
- for (const [flag] of flags) {
- test(`throws missing value for ${flag}`, () => {
- expect(() => parseArgs([flag as string], cwd)).toThrow(`Missing value for ${flag}`);
- });
- }
-});
\ No newline at end of file
diff --git a/src/tests/core-functionality.test.ts b/src/tests/core-functionality.test.ts
deleted file mode 100644
index f05822d..0000000
--- a/src/tests/core-functionality.test.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-// We're keeping only the most essential tests for now
-// TODO: Expand these tests in a future update
-
-import { describe, test, expect } from "bun:test";
-import * as fileUtils from "../file-utils";
-import { parseArgs } from "../cli";
-
-describe("Core gitlapse functionality", () => {
- // Test CLI parsing which is core to the application
- test("CLI parsing correctly handles default values", () => {
- // Test with minimal arguments
- const config = parseArgs([], "/test/dir");
-
- // Verify default values
- expect(config.outDir).toBe("/test/dir/timelapse");
- expect(config.width).toBe(1440);
- expect(config.height).toBe(720);
- expect(config.waitBeforeMs).toBe(3000);
- expect(config.waitAfterMs).toBe(0);
- expect(config.route).toBe("/");
- expect(config.port).toBe(3000);
- expect(config.fps).toBe(12);
- });
-});
\ No newline at end of file
diff --git a/src/tests/file-utils.test.ts b/src/tests/file-utils.test.ts
deleted file mode 100644
index a6eb063..0000000
--- a/src/tests/file-utils.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { describe, test, expect } from "bun:test";
-import { findLastProcessedFrame, getLastCommitInfo } from "../file-utils";
-
-describe("findLastProcessedFrame", () => {
- test("returns -1 when output directory doesn't exist", () => {
- const mockDeps = {
- existsSync: () => false,
- execCommand: () => ""
- };
-
- expect(findLastProcessedFrame("/test/dir/out", "/test/dir/out/frame_", mockDeps)).toBe(-1);
- });
-
- test("returns -1 when no frames found", () => {
- const mockDeps = {
- existsSync: () => true,
- execCommand: () => ""
- };
-
- expect(findLastProcessedFrame("/test/dir/out", "/test/dir/out/frame_", mockDeps)).toBe(-1);
- });
-
- test("finds the highest frame number", () => {
- const mockDeps = {
- existsSync: () => true,
- execCommand: () => "frame_1_abc.png\nframe_5_def.png\nframe_3_ghi.png"
- };
-
- expect(findLastProcessedFrame("/test/dir/out", "/test/dir/out/frame_", mockDeps)).toBe(5);
- });
-
- test("handles malformed frame names", () => {
- const mockDeps = {
- existsSync: () => true,
- execCommand: () => "frame_1_abc.png\nframe_xyz.png\nframe_3_def.png\ninvalid.png"
- };
-
- expect(findLastProcessedFrame("/test/dir/out", "/test/dir/out/frame_", mockDeps)).toBe(3);
- });
-
- test("handles errors gracefully", () => {
- const mockDeps = {
- existsSync: () => true,
- execCommand: () => { throw new Error("Test error"); }
- };
-
- expect(findLastProcessedFrame("/test/dir/out", "/test/dir/out/frame_", mockDeps)).toBe(-1);
- });
-
- test("uses readdirSync when available", () => {
- const mockDeps: any = {
- existsSync: () => true,
- readdirSync: () => [
- "frame_3_aaa.png",
- "frame_7_bbb.png",
- "other.txt"
- ],
- execCommand: () => { throw new Error("Should not use execCommand"); }
- };
- expect(findLastProcessedFrame("/out", "/out/frame_", mockDeps)).toBe(7);
- });
-
- test("returns -1 when readdirSync returns no frames", () => {
- const mockDeps: any = {
- existsSync: () => true,
- readdirSync: () => ["foo.png", "bar.txt"],
- execCommand: () => { throw new Error("Should not use execCommand"); }
- };
- expect(findLastProcessedFrame("/out", "/out/frame_", mockDeps)).toBe(-1);
- });
-});
-
-describe("getLastCommitInfo", () => {
- test("returns undefined for invalid commit index", () => {
- const commits = ["sha1", "sha2", "sha3"];
-
- expect(getLastCommitInfo(-1, commits)).toBeUndefined();
- expect(getLastCommitInfo(3, commits)).toBeUndefined();
- });
-
- test("returns commit info for valid index", () => {
- const commits = ["sha1", "sha2", "sha3"];
- const mockDeps = {
- execCommand: () => "Test commit message"
- };
-
- const result = getLastCommitInfo(1, commits, mockDeps);
-
- expect(result).toBeDefined();
- expect(result?.sha).toBe("sha2");
- expect(result?.shortSha).toBe("sha2".substring(0, 8));
- expect(result?.message).toBe("Test commit message");
- });
-
- test("handles git command errors gracefully", () => {
- const commits = ["sha1", "sha2", "sha3"];
- const mockDeps = {
- execCommand: () => { throw new Error("Git command failed"); }
- };
-
- expect(getLastCommitInfo(1, commits, mockDeps)).toBeUndefined();
- });
-});
\ No newline at end of file
diff --git a/src/tests/git-utils.test.ts b/src/tests/git-utils.test.ts
deleted file mode 100644
index 867d457..0000000
--- a/src/tests/git-utils.test.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { expect, test, describe } from "bun:test";
-import {
- getCurrentBranch,
- safetyCheck
-} from "../git-utils";
-import fs from "fs";
-import path from "path";
-
-describe("Git utilities core functionality", () => {
- // Simple test that verifies the function exists and returns a string
- test("getCurrentBranch returns a string when run in a valid git repo", () => {
- try {
- const branch = getCurrentBranch();
- expect(typeof branch).toBe("string");
- expect(branch.length).toBeGreaterThan(0);
- } catch (error) {
- // If this fails, it might be running in a CI environment without a git repo
- // Just skip the test in that case
- console.log("Skipping getCurrentBranch test, not in a git repo");
- }
- });
-});
\ No newline at end of file
diff --git a/src/tests/logger.test.ts b/src/tests/logger.test.ts
deleted file mode 100644
index 9492507..0000000
--- a/src/tests/logger.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { describe, test, expect, beforeEach, afterEach } from "bun:test";
-import { log, pretty } from "../logger";
-
-describe("logger", () => {
- // Save original console.log
- const originalConsoleLog = console.log;
- let logs: string[] = [];
-
- beforeEach(() => {
- logs = [];
- console.log = (...args: any[]) => {
- logs.push(args.join(" "));
- };
- });
-
- afterEach(() => {
- console.log = originalConsoleLog;
- });
-
- test("log doesn't output anything when DEBUG is not set", () => {
- const originalDebug = process.env.DEBUG;
- process.env.DEBUG = "";
-
- log("Test message");
-
- expect(logs.length).toBe(0);
-
- // Restore DEBUG environment variable
- process.env.DEBUG = originalDebug;
- });
-
- test("log outputs message when DEBUG is set", () => {
- const originalDebug = process.env.DEBUG;
- process.env.DEBUG = "true";
-
- log("Test message");
-
- expect(logs.length).toBe(1);
- expect(logs[0]).toBe("Test message");
-
- // Restore DEBUG environment variable
- process.env.DEBUG = originalDebug;
- });
-
- test("pretty prints with info color by default", () => {
- pretty("Info message");
-
- expect(logs.length).toBe(1);
- // Expecting cyan color and the message
- const logMsg = logs[0];
- if (logMsg) {
- expect(logMsg.includes("\x1b[36m")).toBe(true);
- expect(logMsg.includes("Info message")).toBe(true);
- }
- });
-
- test("pretty prints with success color", () => {
- pretty("Success message", "success");
-
- expect(logs.length).toBe(1);
- // Expecting green color and the message
- const logMsg = logs[0];
- if (logMsg) {
- expect(logMsg.includes("\x1b[32m")).toBe(true);
- expect(logMsg.includes("Success message")).toBe(true);
- }
- });
-
- test("pretty prints with warning color", () => {
- pretty("Warning message", "warning");
-
- expect(logs.length).toBe(1);
- // Expecting yellow color and the message
- const logMsg = logs[0];
- if (logMsg) {
- expect(logMsg.includes("\x1b[33m")).toBe(true);
- expect(logMsg.includes("Warning message")).toBe(true);
- }
- });
-
- test("pretty prints with error color", () => {
- pretty("Error message", "error");
-
- expect(logs.length).toBe(1);
- // Expecting red color and the message
- const logMsg = logs[0];
- if (logMsg) {
- expect(logMsg.includes("\x1b[31m")).toBe(true);
- expect(logMsg.includes("Error message")).toBe(true);
- }
- });
-});
\ No newline at end of file
diff --git a/src/tests/package-utils.test.ts b/src/tests/package-utils.test.ts
deleted file mode 100644
index 8fc8607..0000000
--- a/src/tests/package-utils.test.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// We're skipping this test file for now as it requires filesystem and child process mocking
-// TODO: Implement these tests in a future update
\ No newline at end of file
diff --git a/src/tests/resume-utils.test.ts b/src/tests/resume-utils.test.ts
deleted file mode 100644
index 76bba4e..0000000
--- a/src/tests/resume-utils.test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import { describe, test, expect } from "bun:test";
-import { checkResumeAndPrompt } from "../resume-utils";
-import type { ResumeOptions, ResumeDependencies } from "../resume-utils";
-
-describe("checkResumeAndPrompt", () => {
- const defaultOptions: ResumeOptions = {
- outDir: "/test/dir/out",
- framesPattern: "/test/dir/out/frame_",
- commits: ["sha1", "sha2", "sha3"]
- };
-
- test("returns 0 when no frames found", async () => {
- // Create mock dependencies
- const mockDeps: ResumeDependencies = {
- fileUtils: {
- findLastProcessedFrame: () => -1,
- getLastCommitInfo: () => undefined
- },
- logger: {
- pretty: () => {}
- },
- userInteraction: {
- getUserConfirmation: async () => true
- }
- };
-
- const result = await checkResumeAndPrompt(defaultOptions, mockDeps);
- expect(result).toBe(0);
- });
-
- test("returns 0 when last commit info not found", async () => {
- // Create mock dependencies
- const mockDeps: ResumeDependencies = {
- fileUtils: {
- findLastProcessedFrame: () => 1,
- getLastCommitInfo: () => undefined
- },
- logger: {
- pretty: () => {}
- },
- userInteraction: {
- getUserConfirmation: async () => true
- }
- };
-
- const result = await checkResumeAndPrompt(defaultOptions, mockDeps);
- expect(result).toBe(0);
- });
-
- test("returns next index when user confirms resume", async () => {
- // Create mock dependencies
- const mockDeps: ResumeDependencies = {
- fileUtils: {
- findLastProcessedFrame: () => 1,
- getLastCommitInfo: () => ({
- sha: "sha2",
- shortSha: "sha2",
- message: "Test commit"
- })
- },
- logger: {
- pretty: () => {}
- },
- userInteraction: {
- getUserConfirmation: async () => true
- }
- };
-
- const result = await checkResumeAndPrompt(defaultOptions, mockDeps);
- expect(result).toBe(2); // Next index after index 1
- });
-
- test("returns 0 when user declines resume", async () => {
- // Create mock dependencies
- const mockDeps: ResumeDependencies = {
- fileUtils: {
- findLastProcessedFrame: () => 1,
- getLastCommitInfo: () => ({
- sha: "sha2",
- shortSha: "sha2",
- message: "Test commit"
- })
- },
- logger: {
- pretty: () => {}
- },
- userInteraction: {
- getUserConfirmation: async () => false
- }
- };
-
- const result = await checkResumeAndPrompt(defaultOptions, mockDeps);
- expect(result).toBe(0); // Start from beginning
- });
-
- test("returns -1 when all commits are already processed", async () => {
- // Create mock dependencies
- const mockDeps: ResumeDependencies = {
- fileUtils: {
- findLastProcessedFrame: () => 2,
- getLastCommitInfo: () => ({
- sha: "sha3",
- shortSha: "sha3",
- message: "Test commit"
- })
- },
- logger: {
- pretty: () => {}
- },
- userInteraction: {
- getUserConfirmation: async () => true
- }
- };
-
- const result = await checkResumeAndPrompt(defaultOptions, mockDeps);
- expect(result).toBe(-1); // Signal that all commits are already processed
- });
-});
\ No newline at end of file
diff --git a/src/tests/screenshot-utils.test.ts b/src/tests/screenshot-utils.test.ts
deleted file mode 100644
index 9598b3f..0000000
--- a/src/tests/screenshot-utils.test.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { describe, test, expect } from 'bun:test';
-import { parseArgs } from '../cli';
-
-// Instead of trying to mock complex Puppeteer types, test the CLI options
-// which will eventually affect how screenshots are taken
-
-describe('Screenshot options', () => {
- test('fullscreen option should be enabled when flag is provided', () => {
- const config = parseArgs(['--fullscreen']);
- expect(config.fullscreen).toBe(true);
- });
-
- test('fullscreen option should be disabled by default', () => {
- const config = parseArgs([]);
- expect(config.fullscreen).toBe(false);
- });
-
- test('density option should use provided value', () => {
- const config = parseArgs(['--density', '3']);
- expect(config.density).toBe(3);
- });
-
- test('density option should default to 2', () => {
- const config = parseArgs([]);
- expect(config.density).toBe(2);
- });
-});
\ No newline at end of file
diff --git a/src/tests/server-utils.test.ts b/src/tests/server-utils.test.ts
deleted file mode 100644
index 852d823..0000000
--- a/src/tests/server-utils.test.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// We're skipping this test file for now as it requires complex mocking of child processes
-// TODO: Implement these tests in a future update
\ No newline at end of file
diff --git a/src/tests/user-interaction.test.ts b/src/tests/user-interaction.test.ts
deleted file mode 100644
index 3783e48..0000000
--- a/src/tests/user-interaction.test.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { describe, test, expect } from "bun:test";
-import {
- getUserConfirmation
-} from "../user-interaction";
-
-describe("User Interaction", () => {
- test("getUserConfirmation returns true in test environment", async () => {
- process.env.NODE_ENV = 'test';
- const result = await getUserConfirmation("Confirm?");
- expect(result).toBe(true);
- process.env.NODE_ENV = 'development';
- });
-});
\ No newline at end of file
diff --git a/src/tests/video-utils.test.ts b/src/tests/video-utils.test.ts
deleted file mode 100644
index 11193fc..0000000
--- a/src/tests/video-utils.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { expect, test, describe, jest, beforeEach, afterEach } from "bun:test";
-import { findCapturedFrames, generateTimeLapseVideo } from "../video-utils";
-import { execSync, spawn } from "child_process";
-import fs from "fs";
-import path from "path";
-import os from "os";
-
-describe("Video utilities", () => {
- // Test the generateTimeLapseVideo with simpler validation of the fullscreen parameter
- describe("Video generation with fullscreen option", () => {
- test("generateTimeLapseVideo should accept a fullscreen parameter", () => {
- // Validate the function signature - this is a simple type test
- // We'll check that the function at least accepts the fullscreen parameter
- // without trying to run the actual code (which would need complex mocking)
- const videoFunction = generateTimeLapseVideo;
-
- // Check that function accepts 6 parameters, with the last one being the
- // fullscreen option. This is a bit of a hack but useful for checking
- // that our function at least has the correct signature.
- expect(videoFunction.length).toBeGreaterThan(4);
- });
- });
-
- describe("findCapturedFrames", () => {
- // Test with real files in a temporary directory
- test("should filter only PNG files with matching prefix", () => {
- const tmpDir = path.join(os.tmpdir(), `test-frames-${Date.now()}`);
- try {
- // Create test directory with test files
- fs.mkdirSync(tmpDir, { recursive: true });
-
- // Create test files
- fs.writeFileSync(path.join(tmpDir, "frame_1_abc.png"), "test");
- fs.writeFileSync(path.join(tmpDir, "frame_2_def.png"), "test");
- fs.writeFileSync(path.join(tmpDir, "other_file.png"), "test");
- fs.writeFileSync(path.join(tmpDir, "frame_3_ghi.txt"), "test");
-
- // Run the function
- const result = findCapturedFrames(tmpDir, "frame_");
-
- // Sort for consistent comparison
- const sortedResult = result.sort();
-
- // Verify results - only PNG files with matching prefix should be returned
- expect(sortedResult.length).toBe(2);
- expect(sortedResult[0]?.endsWith("frame_1_abc.png")).toBe(true);
- expect(sortedResult[1]?.endsWith("frame_2_def.png")).toBe(true);
- } finally {
- // Clean up
- try {
- if (fs.existsSync(tmpDir)) {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- }
- } catch (error) {
- console.error("Error cleaning up test directory:", error);
- }
- }
- });
-
- test("should return empty array when directory is empty", () => {
- const tmpDir = path.join(os.tmpdir(), `test-empty-${Date.now()}`);
- try {
- // Create empty test directory
- fs.mkdirSync(tmpDir, { recursive: true });
-
- // Run the function
- const result = findCapturedFrames(tmpDir, "frame_");
-
- // Verify results
- expect(result).toEqual([]);
- } finally {
- // Clean up
- try {
- if (fs.existsSync(tmpDir)) {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- }
- } catch (error) {
- console.error("Error cleaning up test directory:", error);
- }
- }
- });
-
- test("should return empty array when directory doesn't exist", () => {
- const nonExistentDir = path.join(os.tmpdir(), `non-existent-${Date.now()}`);
-
- // Run the function with non-existent directory
- const result = findCapturedFrames(nonExistentDir, "frame_");
-
- // Verify results
- expect(result).toEqual([]);
- });
- });
-});
\ No newline at end of file
diff --git a/src/user-interaction.ts b/src/user-interaction.ts
deleted file mode 100644
index 86f788b..0000000
--- a/src/user-interaction.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { spawn } from 'child_process';
-import { pretty, log } from './logger';
-
-/**
- * Options for user confirmations
- */
-export interface ConfirmationOptions {
- defaultValue?: boolean;
-}
-
-/**
- * Prompt user for confirmation using gum with proper stdio handling
- * In test environments, defaults to specified value or true
- *
- * @param prompt - The prompt message to display
- * @param options - Optional confirmation options
- * @returns Promise resolving to boolean based on user input
- */
-export async function getUserConfirmation(
- prompt: string,
- options: ConfirmationOptions = {}
-): Promise {
- // In test environment, return the specified default or true
- if (process.env.NODE_ENV === 'test') {
- return Promise.resolve(options.defaultValue ?? true);
- }
-
- return new Promise(resolve => {
- const { defaultValue } = options;
- const args = ['confirm'];
-
- if (defaultValue === true) {
- args.push('--default');
- }
-
- args.push(prompt);
-
- // Must use spawn with stdio: 'inherit' to properly show interactive prompts
- const gumProcess = spawn('gum', args, {
- stdio: 'inherit'
- });
-
- gumProcess.on('close', code => {
- // Exit code 0 means "Yes", any other means "No"
- resolve(code === 0);
- });
- });
-}
-
-/**
- * Prompt user for textual input using gum with proper stdio handling
- *
- * @param prompt - The prompt message to display
- * @param defaultValue - Optional default value if user provides empty input
- * @returns Promise resolving to user input string
- */
-export async function getUserInput(
- prompt: string,
- defaultValue?: string
-): Promise {
- // In test environment, return the default value or empty string
- if (process.env.NODE_ENV === 'test') {
- return Promise.resolve(defaultValue || '');
- }
-
- const args = ['input', '--placeholder', prompt];
-
- if (defaultValue) {
- args.push('--value', defaultValue);
- }
-
- return new Promise((resolve) => {
- // Use stdin/stdout redirection for capturing input
- const gumProcess = spawn('gum', args, {
- stdio: ['inherit', 'pipe', 'inherit']
- });
-
- let output = '';
-
- if (gumProcess.stdout) {
- gumProcess.stdout.on('data', (data) => {
- output += data.toString();
- });
- }
-
- gumProcess.on('close', () => {
- const value = output.trim();
- resolve(value || defaultValue || '');
- });
- });
-}
-
-/**
- * Display a multi-choice menu using gum with proper stdio handling
- *
- * @param prompt - The prompt message to display
- * @param choices - Array of choice objects with value and label
- * @param defaultIndex - Optional index of default choice
- * @returns Promise resolving to the value of the selected choice
- */
-export async function getUserChoice(
- prompt: string,
- choices: Array<{ value: T, label: string }>,
- defaultIndex = 0
-): Promise {
- // In test environment, return the default choice
- if (process.env.NODE_ENV === 'test') {
- const defaultChoice = choices[defaultIndex];
- if (!defaultChoice) {
- throw new Error("No default choice available at index: " + defaultIndex);
- }
- return Promise.resolve(defaultChoice.value);
- }
-
- if (choices.length === 0) {
- throw new Error("No choices provided for selection menu");
- }
-
- // Extract just the labels for gum
- const labels = choices.map(choice => choice.label);
-
- // Build gum args
- const args = ['choose', '--header', prompt, ...labels];
-
- return new Promise((resolve) => {
- // Use stdin/stdout redirection for interactive selection
- const gumProcess = spawn('gum', args, {
- stdio: ['inherit', 'pipe', 'inherit']
- });
-
- let output = '';
-
- if (gumProcess.stdout) {
- gumProcess.stdout.on('data', (data) => {
- output += data.toString();
- });
- }
-
- gumProcess.on('close', (code) => {
- const selectedLabel = output.trim();
-
- // Find the matching choice
- const selectedChoice = choices.find(choice => choice.label === selectedLabel);
-
- if (selectedChoice) {
- resolve(selectedChoice.value);
- } else {
- // If not found or user cancelled, return default
- const defaultChoice = choices[defaultIndex];
- if (!defaultChoice) {
- throw new Error("No default choice available");
- }
- resolve(defaultChoice.value);
- }
- });
- });
-}
\ No newline at end of file
diff --git a/src/video-utils.ts b/src/video-utils.ts
deleted file mode 100644
index b8ad0e8..0000000
--- a/src/video-utils.ts
+++ /dev/null
@@ -1,528 +0,0 @@
-import fs from "fs";
-import path from "path";
-import os from "os";
-import { execSync, spawn } from "child_process";
-import { log, pretty } from "./logger";
-import { promisify } from "util";
-import { createHash } from "crypto";
-
-// Promisify fs functions for better async performance
-const copyFileAsync = promisify(fs.copyFile);
-const mkdirAsync = promisify(fs.mkdir);
-const existsAsync = promisify(fs.exists);
-const readdirAsync = promisify(fs.readdir);
-
-// Check if hardware acceleration is available for ffmpeg
-function detectHardwareAcceleration(): string {
- try {
- // Check for macOS hardware acceleration
- if (process.platform === 'darwin') {
- return "-hwaccel videotoolbox -hwaccel_output_format yuv420p";
- }
-
- // Check for NVIDIA GPU acceleration
- const nvidiaSmiOutput = execSync("nvidia-smi -L 2>/dev/null || echo 'not found'").toString();
- if (!nvidiaSmiOutput.includes("not found")) {
- return "-hwaccel cuda -hwaccel_output_format yuv420p";
- }
-
- // Check for Intel QuickSync acceleration
- const vaInfoOutput = execSync("vainfo 2>/dev/null || echo 'not found'").toString();
- if (!vaInfoOutput.includes("not found") && vaInfoOutput.includes("VA-API")) {
- return "-hwaccel vaapi -hwaccel_output_format yuv420p";
- }
-
- // No hardware acceleration detected
- return "";
- } catch (error) {
- log("Hardware acceleration detection failed, using software encoding");
- return "";
- }
-}
-
-/**
- * Generate a hash for an image file - used for deduplication
- */
-async function generateImageHash(filePath: string): Promise {
- try {
- const data = fs.readFileSync(filePath);
- const hash = createHash('md5').update(data).digest('hex');
- return hash;
- } catch (error) {
- log(`Error generating hash for ${filePath}: ${error}`);
- // Return a unique string to prevent duplication errors
- return `error-${Date.now()}-${Math.random()}`;
- }
-}
-
-/**
- * Filter out duplicate frames based on image content
- */
-async function removeDuplicateFrames(frames: string[]): Promise {
- pretty("Checking for duplicate frames...", "info");
-
- if (frames.length <= 1) {
- return frames;
- }
-
- const uniqueFrames: string[] = [];
- const seenHashes = new Set();
-
- // Use a sliding window approach for better performance with many frames
- const batchSize = 10;
- for (let i = 0; i < frames.length; i += batchSize) {
- const batch = frames.slice(i, i + batchSize);
- const hashPromises = batch.map(frame => {
- if (!frame) return Promise.resolve("");
- return generateImageHash(frame);
- });
- const hashes = await Promise.all(hashPromises);
-
- batch.forEach((frame, index) => {
- if (!frame) return;
- const hash = hashes[index];
- if (hash && !seenHashes.has(hash)) {
- seenHashes.add(hash);
- uniqueFrames.push(frame);
- } else if (hash) {
- log(`Duplicate frame detected: ${frame}`);
- }
- });
- }
-
- const duplicatesRemoved = frames.length - uniqueFrames.length;
- if (duplicatesRemoved > 0) {
- pretty(`Removed ${duplicatesRemoved} duplicate frames.`, "info");
- } else {
- log("No duplicate frames found.");
- }
-
- return uniqueFrames;
-}
-
-/**
- * Generate a video from captured frames with improved performance
- */
-export async function generateTimeLapseVideo(
- outDir: string,
- framesPattern: string,
- width: number,
- height: number,
- fps: number,
- fullscreen?: boolean,
- densityValue?: number
-): Promise {
- if (!framesPattern) {
- throw new Error("Frame pattern is required");
- }
- pretty("Creating timelapse video...", "info");
-
- // Find captured frames
- const frameFiles = findCapturedFrames(outDir, framesPattern);
-
- if (frameFiles.length === 0) {
- pretty("❌ No frames were created. Cannot generate video.", "error");
- process.exit(1);
- }
-
- // Remove duplicate frames
- const uniqueFrames = await removeDuplicateFrames(frameFiles);
-
- // Special handling for single frame case
- if (uniqueFrames.length === 1 && uniqueFrames[0]) {
- log("Only one frame detected, will duplicate it to create a valid video");
- // Duplicate the frame to ensure we can create a video (needs at least 2 frames)
- uniqueFrames.push(uniqueFrames[0]);
- }
-
- // If fullscreen, determine max height of all frames then adjust for density
- let maxHeight = height;
- // Use provided density parameter or default to 2
- const densityFactor = (typeof densityValue === 'number' && densityValue > 0) ? densityValue : 2;
-
- if (fullscreen) {
- try {
- pretty("Fullscreen mode enabled, finding tallest frame...", "info");
- for (const frame of uniqueFrames) {
- if (!frame) continue;
-
- // Use ImageMagick to get dimensions
- try {
- const dimensions = execSync(`identify -format "%h" "${frame}"`).toString().trim();
- const frameHeight = parseInt(dimensions, 10);
- if (!isNaN(frameHeight) && frameHeight > maxHeight) {
- maxHeight = frameHeight;
- log(`New max height found: ${maxHeight}px from frame ${frame}`);
- }
- } catch (imgError) {
- log(`Error getting dimensions from ${frame}: ${imgError}`);
- }
- }
-
- // Adjust height based on density (screenshot dimensions are multiplied by density)
- const adjustedMaxHeight = Math.ceil(maxHeight / densityFactor);
- pretty(`Adjusting max height from ${maxHeight}px to ${adjustedMaxHeight}px (accounting for density ${densityFactor})`, "info");
- maxHeight = adjustedMaxHeight;
-
- // Ensure max height is at least the configured height
- if (maxHeight < height) {
- maxHeight = height;
- }
-
- pretty(`Using max height of ${maxHeight}px for video output`, "info");
- } catch (error) {
- log(`Error determining max height, falling back to configured height: ${error}`);
- maxHeight = height;
- }
- }
-
- pretty(`Found ${uniqueFrames.length} unique frames, generating video...`, "info");
-
- // Use a consistent filename for easier access
- const outputVideoPath = path.join(outDir, `timelapse.mp4`);
-
- try {
- // Create a temp directory with numerically named files that FFmpeg can use with a pattern
- const tmpDir = path.join(os.tmpdir(), `ffmpeg-frames-${Date.now()}`);
- await mkdirAsync(tmpDir, { recursive: true });
-
- try {
- log(`Created temporary directory for frame sequence: ${tmpDir}`);
-
- // Sort frames by numeric order with optimized regex once
- const frameNumbers = new Map();
- const regex = /frame_(\d+)_/;
-
- // Create a filtered, non-undefined array of frames
- const validFrames: string[] = [];
- for (const frame of uniqueFrames) {
- if (typeof frame === 'string') {
- validFrames.push(frame);
- }
- }
-
- // Process the valid frames
- for (const frame of validFrames) {
- const match = regex.exec(frame);
- if (match && match[1]) {
- frameNumbers.set(frame, parseInt(match[1]));
- } else {
- frameNumbers.set(frame, 0);
- }
- }
-
- const sortedFrames = [...validFrames].sort((a, b) => {
- return (frameNumbers.get(a) || 0) - (frameNumbers.get(b) || 0);
- });
-
- // Create copy operations in batches for better performance
- const batchSize = 10; // Adjust based on system capabilities
- const copyPromises: Promise[] = [];
-
- for (let i = 0; i < sortedFrames.length; i++) {
- const sourcePath = sortedFrames[i];
- if (!sourcePath) continue;
-
- const destPath = path.join(tmpDir, `img_${String(i).padStart(6, '0')}.png`);
- copyPromises.push(copyFileAsync(sourcePath, destPath));
-
- // Process in batches to avoid overwhelming the file system
- if (copyPromises.length >= batchSize || i === sortedFrames.length - 1) {
- await Promise.all(copyPromises);
- copyPromises.length = 0;
- }
- }
-
- // Detect hardware acceleration capabilities
- const hwAccel = detectHardwareAcceleration();
-
- // Construct the ffmpeg command with high quality settings
- const imgPattern = path.join(tmpDir, 'img_%06d.png');
-
- // Enhanced ffmpeg command for higher quality output:
- // - For fullscreen mode: Preserve aspect ratio exactly, align top
- // - For normal mode: Keep aspect ratio, center both vertically and horizontally
- const vfFilter = fullscreen
- ? `scale=w=${width}:h=-1,pad=${width}:${maxHeight}:0:0:black`
- : `scale='min(${width},iw)':min(${height},ih):force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`;
-
- const ffmpegCmd = `ffmpeg -y -loglevel error ${hwAccel} -framerate ${fps} -i "${imgPattern}" \
- -vframes ${sortedFrames.length} \
- -c:v libx264 -preset slow -tune stillimage -crf 15 \
- -vf "${vfFilter}" \
- -sws_flags neighbor -pix_fmt yuv420p -color_primaries 1 -color_trc 1 -colorspace 1 \
- -movflags faststart -g 1 -bf 0 "${outputVideoPath}"`;
-
-
- log(`Running FFmpeg command with high quality settings: ${ffmpegCmd}`);
-
- // Use spawn instead of execSync for better performance with large files
- return new Promise((resolve, reject) => {
- const ffmpeg = spawn('bash', ['-c', ffmpegCmd], {
- stdio: ['ignore', 'pipe', 'pipe']
- });
-
- let stdoutChunks: Buffer[] = [];
- let stderrChunks: Buffer[] = [];
-
- ffmpeg.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
- ffmpeg.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
-
- ffmpeg.on('close', async (code) => {
- if (code !== 0) {
- const stderr = Buffer.concat(stderrChunks).toString();
- log(`FFmpeg error: ${stderr}`);
-
- // Try fallback approach
- const fallbackResult = await tryFallbackVideoGeneration(
- outDir, framesPattern, sortedFrames, fps, outputVideoPath, fullscreen, densityValue
- );
- resolve(fallbackResult);
- } else {
- // Verify the video was created
- const exists = await existsAsync(outputVideoPath);
- if (!exists) {
- reject(new Error("Failed to create video file - output file does not exist"));
- return;
- }
-
- pretty("✅ Video creation successful!", "success");
- resolve(outputVideoPath);
- }
-
- // Clean up the temporary directory asynchronously
- try {
- execSync(`rm -rf "${tmpDir}"`);
- log(`Cleaned up temporary directory: ${tmpDir}`);
- } catch (cleanupError) {
- log(`Warning: Failed to clean up temporary directory: ${cleanupError}`);
- }
- });
-
- ffmpeg.on('error', async (err) => {
- log(`FFmpeg process error: ${err.message}`);
-
- // Try fallback approach
- const fallbackResult = await tryFallbackVideoGeneration(
- outDir, framesPattern, sortedFrames, fps, outputVideoPath, fullscreen, densityValue
- );
- resolve(fallbackResult);
-
- // Clean up the temporary directory asynchronously
- try {
- execSync(`rm -rf "${tmpDir}"`);
- } catch (cleanupError) {
- log(`Warning: Failed to clean up temporary directory: ${cleanupError}`);
- }
- });
- });
- } catch (error) {
- // Clean up the temporary directory
- try {
- execSync(`rm -rf "${tmpDir}"`);
- log(`Cleaned up temporary directory after error: ${tmpDir}`);
- } catch (cleanupError) {
- log(`Warning: Failed to clean up temporary directory: ${cleanupError}`);
- }
-
- throw error;
- }
- } catch (ffmpegError) {
- pretty("❌ Error creating video:", "error");
- pretty(ffmpegError instanceof Error ? ffmpegError.message : String(ffmpegError), "error");
-
- // Try fallback approach
- return tryFallbackVideoGeneration(outDir, framesPattern, uniqueFrames, fps, outputVideoPath, fullscreen, densityValue);
- }
-}
-
-/**
- * Find all captured frames in the output directory with improved performance
- */
-export function findCapturedFrames(outDir: string, framesPattern: string): string[] {
- const basename = path.basename(framesPattern);
- try {
- const entries = fs.readdirSync(outDir);
-
- // Use filter with a more efficient predicate
- const prefix = basename;
- const suffix = '.png';
-
- return entries
- .filter(f => {
- return f.indexOf(prefix) === 0 && f.indexOf(suffix, f.length - suffix.length) !== -1;
- })
- .map(f => path.join(outDir, f));
- } catch (err) {
- return [];
- }
-}
-
-/**
- * Improved fallback approach for video generation when the main method fails
- */
-async function tryFallbackVideoGeneration(
- outDir: string,
- framesPattern: string,
- allFrames: string[],
- fps: number,
- outputVideoPath: string,
- fullscreen?: boolean,
- densityValue?: number
-): Promise {
- try {
- log("Trying alternative approach with glob pattern...");
-
- // Create a temporary directory for the frames
- const tmpDir = path.join(os.tmpdir(), `ffmpeg-frames-fallback-${Date.now()}`);
- await mkdirAsync(tmpDir, { recursive: true });
- log(`Created temp directory for fallback approach: ${tmpDir}`);
-
- try {
- // Create sequentially named copies of the frames in parallel batches
- const batchSize = 10;
- const copyPromises: Promise[] = [];
-
- for (let i = 0; i < allFrames.length; i++) {
- const sourcePath = allFrames[i];
- if (!sourcePath) continue;
-
- const destPath = path.join(tmpDir, `img_${String(i).padStart(6, '0')}.png`);
- copyPromises.push(copyFileAsync(sourcePath, destPath));
-
- // Process in batches to avoid overwhelming the file system
- if (copyPromises.length >= batchSize || i === allFrames.length - 1) {
- await Promise.all(copyPromises);
- copyPromises.length = 0;
- }
- }
-
- // Use a direct sequence pattern for more reliable frame ordering
- // Use a simpler command but still prioritize quality
- const imgPattern = path.join(tmpDir, 'img_%06d.png');
-
- // Determine max height for fullscreen mode with adjustment for density
- let maxHeight = 720; // Default height
- const videoWidth = 1440; // Default width
- // Use provided density parameter or default to 2
- const densityFactor = (typeof densityValue === 'number' && densityValue > 0) ? densityValue : 2;
-
- if (fullscreen) {
- try {
- // First pass: find the tallest frame
- for (const frame of allFrames) {
- if (!frame) continue;
- try {
- const dimensions = execSync(`identify -format "%h" "${frame}"`).toString().trim();
- const frameHeight = parseInt(dimensions, 10);
- if (!isNaN(frameHeight) && frameHeight > maxHeight) {
- maxHeight = frameHeight;
- log(`Fallback: New max height found: ${maxHeight} from frame ${frame}`);
- }
- } catch (imgError) {
- log(`Fallback: Error getting dimensions from ${frame}: ${imgError}`);
- }
- }
-
- // Adjust height based on density (screenshot dimensions are multiplied by density)
- const adjustedMaxHeight = Math.ceil(maxHeight / densityFactor);
- log(`Fallback: Adjusting max height from ${maxHeight}px to ${adjustedMaxHeight}px (accounting for density ${densityFactor})`);
- maxHeight = adjustedMaxHeight;
-
- log(`Fallback: Using max height of ${maxHeight} pixels for video output`);
- } catch (error) {
- log(`Fallback: Error determining max height: ${error}`);
- }
- }
-
- // Set up video filter based on fullscreen mode - embed in command properly
- // For fullscreen: Preserve aspect ratio exactly, align top
- const vfFilter = fullscreen
- ? `scale=w=${videoWidth}:h=-1,pad=${videoWidth}:${maxHeight}:0:0:black`
- : `scale=${videoWidth}:720:force_original_aspect_ratio=decrease,pad=${videoWidth}:720:(ow-iw)/2:(oh-ih)/2:black`;
-
- // Simple command with error output enabled to help diagnose issues
- const simpleCmd = `ffmpeg -y -framerate ${fps} -i "${imgPattern}" \
- -vframes ${allFrames.length} -c:v libx264 -preset medium \
- -crf 23 -vf "${vfFilter}" -pix_fmt yuv420p "${outputVideoPath}"`;
-
- log(`Using adjusted filter for compatibility: ${vfFilter}`);
-
-
- log(`Running fallback FFmpeg command with medium preset: ${simpleCmd}`);
- execSync(simpleCmd, { stdio: ['ignore', 'pipe', 'pipe'] });
-
- if (await existsAsync(outputVideoPath)) {
- pretty("✅ Video creation successful with fallback method!", "success");
- return outputVideoPath;
- }
- } finally {
- // Clean up the temporary directory
- try {
- execSync(`rm -rf "${tmpDir}"`);
- log(`Cleaned up temporary directory: ${tmpDir}`);
- } catch (cleanupError) {
- log(`Warning: Failed to clean up temporary directory: ${cleanupError}`);
- }
- }
- } catch (fallbackError) {
- log(`Fallback approach failed: ${fallbackError}`);
-
- // Try one very basic fallback before giving up completely
- try {
- log("Attempting final basic fallback...");
- // Ultra basic fallback command - accounting for density
- const defaultWidth = 1440;
- let defaultMaxHeight = 720; // Default if we can't determine
- const densityFactor = 2; // Default density for final fallback (no parameters access here)
-
- // Try to determine the actual max height by checking frames directly
- try {
- pretty("Finding tallest frame for final fallback...", "info");
- // Use find command to get frame paths
- const frames = execSync(`find "${outDir}" -name "frame_*.png"`, { encoding: 'utf8' }).split('\n').filter(Boolean);
-
- // Find tallest frame height
- for (const frame of frames) {
- if (!frame) continue;
- try {
- const dimensions = execSync(`identify -format "%h" "${frame}"`, { encoding: 'utf8' }).trim();
- const frameHeight = parseInt(dimensions, 10);
- if (!isNaN(frameHeight) && frameHeight > defaultMaxHeight) {
- defaultMaxHeight = frameHeight;
- log(`Final fallback: Found taller frame: ${defaultMaxHeight}px (${frame})`);
- }
- } catch (imgError) {
- // Ignore errors and continue with next frame
- }
- }
-
- // Adjust for density
- const adjustedHeight = Math.ceil(defaultMaxHeight / densityFactor);
- pretty(`Final fallback adjusting max height from ${defaultMaxHeight}px to ${adjustedHeight}px (accounting for density ${densityFactor})`, "info");
- defaultMaxHeight = adjustedHeight;
- } catch (error) {
- log(`Error determining max height for final fallback: ${error}`);
- }
- const vf = fullscreen ?
- `-vf "scale=w=${defaultWidth}:h=-1,pad=${defaultWidth}:${defaultMaxHeight}:0:0:black"` :
- '';
- const basicCmd = `ffmpeg -y -pattern_type glob -framerate ${fps} -i "${outDir}/frame_*.png" -c:v libx264 -preset ultrafast ${vf} -pix_fmt yuv420p "${outputVideoPath}"`;
- log(`Running basic fallback command: ${basicCmd}`);
- execSync(basicCmd, { stdio: 'inherit' });
-
- if (fs.existsSync(outputVideoPath)) {
- pretty("✅ Video creation successful with basic fallback method!", "success");
- return outputVideoPath;
- }
- } catch (finalError) {
- log(`Final fallback also failed: ${finalError}`);
- pretty("All video creation attempts failed. Please try manually using ffmpeg.", "error");
- }
-
- // Don't exit the process, allow for graceful handling
- return undefined;
- }
-
- return undefined;
-}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index e9581b3..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "compilerOptions": {
- // Environment setup & latest features
- "lib": ["esnext", "dom"],
- "target": "ESNext",
- "module": "ESNext",
- "moduleDetection": "force",
- "jsx": "react-jsx",
- "allowJs": true,
-
- // Bundler mode
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "verbatimModuleSyntax": true,
- "noEmit": true,
-
- // Best practices
- "strict": true,
- "skipLibCheck": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedIndexedAccess": true,
-
- // Some stricter flags (disabled by default)
- "noUnusedLocals": false,
- "noUnusedParameters": false,
- "noPropertyAccessFromIndexSignature": false
- },
- "include": ["src/**/*"]
-}