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/**/*"] -}