From ef6107f52e838f982170ece4f8999a13ca4c717d Mon Sep 17 00:00:00 2001 From: Justin Mai Date: Tue, 30 Jun 2026 06:39:17 -0700 Subject: [PATCH 1/3] Add storyboard contact sheet flow --- README.md | 5 ++ src/artifacts/storyboard.test.ts | 106 +++++++++++++++++++++++++++ src/artifacts/storyboard.ts | 121 +++++++++++++++++++++++++++++++ src/cli.ts | 33 +++++++++ src/commands/install.ts | 41 +++++++++++ src/commands/stop.test.ts | 84 +++++++++++++++++++++ src/commands/stop.ts | 20 +++++ 7 files changed, 410 insertions(+) create mode 100644 src/artifacts/storyboard.test.ts create mode 100644 src/artifacts/storyboard.ts create mode 100644 src/commands/stop.test.ts diff --git a/README.md b/README.md index d157a65..5722f0c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Each session produces a timestamped folder in `./proofshot-artifacts/`: | `viewer.html` | Standalone interactive viewer with scrub bar, timeline, and Console/Server log tabs | | `SUMMARY.md` | Markdown report with errors, screenshots, and video | | `step-*.png` | Screenshots captured at key moments | +| `storyboard.png` / `storyboard-scenes.json` | Optional FFmpeg storyboard contact sheet plus scene metadata | | `session-log.json` | Action timeline with timestamps and element data | | `server.log` | Dev server stdout/stderr (when using `--run`) | | `console-output.log` | Browser console output | @@ -165,8 +166,12 @@ Stop recording, collect errors, generate proof artifacts. ```bash proofshot stop # Stop session and close browser proofshot stop --no-close # Stop but keep browser open +proofshot stop --storyboard # Also generate a storyboard contact sheet ``` +Storyboard generation is opt-in. It uses FFmpeg scene detection, writes `storyboard.png` plus `storyboard-scenes.json`, leaves `session-log.json` unchanged, and records fallback mode explicitly when no scenes are found. +Use `proofshot storyboard --input ./proofshot-artifacts/` to regenerate a storyboard from an existing session. + ### `proofshot exec` Pass-through to agent-browser with automatic session logging. Captures timestamps, element data, and resolves screenshot paths. diff --git a/src/artifacts/storyboard.test.ts b/src/artifacts/storyboard.test.ts new file mode 100644 index 0000000..bcd3b23 --- /dev/null +++ b/src/artifacts/storyboard.test.ts @@ -0,0 +1,106 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { findExecutablePathMock, spawnSyncMock } = vi.hoisted(() => ({ + findExecutablePathMock: vi.fn(), + spawnSyncMock: vi.fn(), +})); + +vi.mock('../utils/process.js', () => ({ + findExecutablePath: findExecutablePathMock, +})); + +vi.mock('child_process', () => ({ + spawnSync: spawnSyncMock, +})); + +import { generateStoryboardArtifact } from './storyboard.js'; + +describe('generateStoryboardArtifact', () => { + beforeEach(() => { + vi.restoreAllMocks(); + findExecutablePathMock.mockReturnValue('/usr/bin/ffmpeg'); + spawnSyncMock.mockReset(); + }); + + function makeSessionDir(name: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `proofshot-${name}-`)); + const sessionDir = path.join(root, 'session'); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync(path.join(sessionDir, 'session.webm'), ''); + return sessionDir; + } + + it('writes real scene timestamps when ffmpeg reports cuts', () => { + const sessionDir = makeSessionDir('storyboard-scene'); + const outputPath = path.join(sessionDir, 'storyboard.png'); + + spawnSyncMock.mockImplementation((file: string, args: string[]) => { + if (args.some((arg) => arg.includes('showinfo'))) { + return { + stdout: '', + stderr: + '[Parsed_showinfo_0 @ 0x0] n: 1 pts: 100 pts_time:1.0 pos:0\n' + + '[Parsed_showinfo_0 @ 0x0] n: 2 pts: 234 pts_time:2.3 pos:0\n' + + '[Parsed_showinfo_0 @ 0x0] n: 3 pts: 345 pts_time:3.4 pos:0\n' + + '[Parsed_showinfo_0 @ 0x0] n: 4 pts: 456 pts_time:4.6 pos:0\n', + status: 0, + error: undefined, + } as never; + } + + fs.writeFileSync(outputPath, 'png'); + return { stdout: '', stderr: '', status: 0, error: undefined } as never; + }); + + const result = generateStoryboardArtifact({ inputDir: sessionDir, outputPath }); + const storyboard = JSON.parse(fs.readFileSync(path.join(sessionDir, 'storyboard-scenes.json'), 'utf8')); + + expect(result.imagePath).toBe(outputPath); + expect(storyboard.mode).toBe('scene'); + expect(storyboard.scenes).toEqual([ + { label: 'scene-001', timeSec: 1 }, + { label: 'scene-002', timeSec: 2.3 }, + { label: 'scene-003', timeSec: 3.4 }, + { label: 'scene-004', timeSec: 4.6 }, + ]); + expect(spawnSyncMock.mock.calls[0][1].join(' ')).toContain('showinfo'); + expect(spawnSyncMock.mock.calls[1][1].join(' ')).toContain("select='gt(scene"); + }); + + it('falls back immediately when no scenes are detected', () => { + const sessionDir = makeSessionDir('storyboard-fallback'); + const outputPath = path.join(sessionDir, 'storyboard.png'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + spawnSyncMock.mockImplementation((file: string, args: string[]) => { + if (args.some((arg) => arg.includes('showinfo'))) { + return { stdout: '', stderr: '', status: 0, error: undefined } as never; + } + + fs.writeFileSync(outputPath, 'png'); + return { stdout: '', stderr: '', status: 0, error: undefined } as never; + }); + + generateStoryboardArtifact({ inputDir: sessionDir, outputPath }); + const storyboard = JSON.parse(fs.readFileSync(path.join(sessionDir, 'storyboard-scenes.json'), 'utf8')); + + expect(storyboard.mode).toBe('fallback'); + expect(storyboard.scenes).toEqual([]); + expect(spawnSyncMock.mock.calls).toHaveLength(2); + expect(spawnSyncMock.mock.calls[1][1].join(' ')).not.toContain('select=gt(scene'); + expect(logSpy.mock.calls.flat().join('\n')).toContain('Storyboard fallback'); + }); + + it('skips cleanly when ffmpeg is unavailable', () => { + findExecutablePathMock.mockReturnValue(null); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const result = generateStoryboardArtifact({ inputDir: '/tmp/does-not-matter' }); + + expect(result).toEqual({ imagePath: null, jsonPath: null }); + expect(logSpy.mock.calls.flat().join('\n')).toContain('Storyboard unavailable'); + }); +}); diff --git a/src/artifacts/storyboard.ts b/src/artifacts/storyboard.ts new file mode 100644 index 0000000..63a6beb --- /dev/null +++ b/src/artifacts/storyboard.ts @@ -0,0 +1,121 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import chalk from 'chalk'; +import { findExecutablePath } from '../utils/process.js'; + +interface StoryboardOptions { + inputDir: string; + outputPath?: string; + threshold?: number; + grid?: string; + width?: number; +} + +const DEFAULT_THRESHOLD = 0.3; +const DEFAULT_GRID = '4x5'; +const DEFAULT_WIDTH = 1600; +const MIN_SCENES_FOR_SCENE_MODE = 4; +const MAX_SCENES = 20; + +function parseSceneTimes(text: string): number[] { + const seen = new Set(); + const times: number[] = []; + for (const match of text.matchAll(/pts_time:([0-9]+(?:\.[0-9]+)?)/g)) { + const value = Number(match[1]); + if (!Number.isFinite(value) || value < 0) continue; + const time = Math.round(value * 10) / 10; + const key = time.toFixed(1); + if (seen.has(key)) continue; + seen.add(key); + times.push(time); + if (times.length >= MAX_SCENES) break; + } + return times; +} + +function scenesFromTimes(times: number[]) { + return times.map((timeSec, i) => ({ label: `scene-${String(i + 1).padStart(3, '0')}`, timeSec })); +} + +function runFfmpeg(ffmpeg: string, args: string[]): string { + const result = spawnSync(ffmpeg, args, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim(); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(output || `Command failed: ${ffmpeg}`); + return output; +} + +function renderFilter(threshold: number, grid: string, width: number, useSceneSelect: boolean): string { + const cols = Number.parseInt(grid, 10) || 4; + const thumbWidth = Math.max(1, Math.floor(width / cols)); + const prefix = useSceneSelect ? `select='gt(scene\\,${threshold})',` : ''; + return `${prefix}scale=${thumbWidth}:-1:flags=lanczos,tile=${grid}`; +} + +export function generateStoryboardArtifact(options: StoryboardOptions): { + imagePath: string | null; + jsonPath: string | null; +} { + const ffmpeg = findExecutablePath('ffmpeg'); + if (!ffmpeg) { + console.log(chalk.dim('Storyboard unavailable: install ffmpeg to generate storyboard artifacts.')); + return { imagePath: null, jsonPath: null }; + } + + const inputDir = path.resolve(options.inputDir); + const videoPath = ['session.webm', 'session.mp4', 'session.mov'] + .map((name) => path.join(inputDir, name)) + .find((candidate) => fs.existsSync(candidate)); + + if (!videoPath) throw new Error(`No session video found in ${inputDir}`); + + const outputPath = path.resolve(options.outputPath || path.join(inputDir, 'storyboard.png')); + const threshold = options.threshold ?? DEFAULT_THRESHOLD; + const grid = options.grid ?? DEFAULT_GRID; + const width = options.width ?? DEFAULT_WIDTH; + const sceneOutput = runFfmpeg(ffmpeg, ['-hide_banner', '-i', videoPath, '-vf', `select='gt(scene\\,${threshold})',showinfo`, '-an', '-f', 'null', '-']); + const scenes = scenesFromTimes(parseSceneTimes(sceneOutput)); + let mode: 'scene' | 'fallback' = scenes.length >= MIN_SCENES_FOR_SCENE_MODE ? 'scene' : 'fallback'; + + const artifactPath = path.join(inputDir, 'storyboard-scenes.json'); + const writeArtifact = (): void => { + fs.writeFileSync( + artifactPath, + JSON.stringify( + { + videoPath: path.basename(videoPath), + mode, + scenes, + threshold, + grid, + width, + source: 'ffmpeg', + }, + null, + 2, + ) + '\n', + ); + }; + + const render = (useSceneSelect: boolean): void => { + runFfmpeg(ffmpeg, ['-hide_banner', '-i', videoPath, '-vf', renderFilter(threshold, grid, width, useSceneSelect), '-frames:v', '1', outputPath]); + }; + + if (mode === 'scene') { + render(true); + } else { + render(false); + } + + if (mode === 'fallback') { + console.log(chalk.dim('Storyboard fallback: no scene cuts detected; using a plain tile sheet.')); + } + + writeArtifact(); + return { imagePath: outputPath, jsonPath: artifactPath }; +} diff --git a/src/cli.ts b/src/cli.ts index 5be1c3f..c65e3a1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { cleanCommand } from './commands/clean.js'; import { prCommand } from './commands/pr.js'; import { execCommand } from './commands/exec.js'; import { doctorCommand } from './commands/doctor.js'; +import { generateStoryboardArtifact } from './artifacts/storyboard.js'; import { PROOFSHOT_VERSION } from './version.js'; export function createCLI(): Command { @@ -45,10 +46,42 @@ export function createCLI(): Command { .command('stop') .description('Stop session: stop recording, collect errors, bundle proof artifacts') .option('--no-close', 'Don\'t close the browser (keep it open for further use)') + .option('--storyboard', 'Generate a storyboard contact sheet for the session video') .action(async (options) => { await stopCommand(options); }); + program + .command('storyboard') + .description('Generate a storyboard contact sheet from a completed session directory') + .requiredOption('--input ', 'Session directory containing session.webm') + .option('--output ', 'Storyboard image output path') + .option('--threshold ', 'FFmpeg scene-detection threshold', parseFloat) + .option('--grid x', 'Storyboard grid size', '4x5') + .option('--width ', 'Storyboard output width', parseInt) + .action(async (options) => { + try { + const result = generateStoryboardArtifact({ + inputDir: options.input, + outputPath: options.output, + threshold: options.threshold, + grid: options.grid, + width: options.width, + }); + + if (!result.imagePath || !result.jsonPath) { + console.log('Storyboard unavailable: install ffmpeg to generate it.'); + return; + } + + console.log(`✓ Storyboard: ${result.imagePath}`); + console.log(`✓ Scenes: ${result.jsonPath}`); + } catch (error: any) { + console.error(`✗ Storyboard generation failed: ${error?.message || String(error)}`); + process.exit(1); + } + }); + program .command('diff') .description('Compare current screenshots against a baseline') diff --git a/src/commands/install.ts b/src/commands/install.ts index 79a8b25..5fd4fcd 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -5,6 +5,7 @@ import * as readline from 'readline'; import { execSync } from 'child_process'; import chalk from 'chalk'; import { readBundledSkill, getInlineSkillContent } from '../utils/skills.js'; +import { findExecutablePath } from '../utils/process.js'; // --------------------------------------------------------------------------- // Types @@ -42,6 +43,12 @@ interface InstallResult { message?: string; } +interface SystemPrereq { + name: string; + binary: string; + installHint: string; +} + export interface InstallOptions { only?: string; skip?: string; @@ -164,6 +171,37 @@ function getSkillContent(tool: ToolDefinition): string { return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent); } +function getSystemPrereqs(): SystemPrereq[] { + return [ + { + name: 'agent-browser', + binary: 'agent-browser', + installHint: 'Run `npm install -g agent-browser && agent-browser install`.', + }, + { + name: 'ffmpeg', + binary: 'ffmpeg', + installHint: 'On Amazon Linux 2023, try `sudo yum install -y ffmpeg` or install a static build.', + }, + ]; +} + +function printSystemPrereqs(): void { + console.log(''); + console.log(chalk.bold('System prerequisites:')); + console.log(''); + + for (const prereq of getSystemPrereqs()) { + const binaryPath = findExecutablePath(prereq.binary); + const status = binaryPath ? chalk.green('✓') : chalk.yellow('⚠'); + console.log(`${status} ${prereq.name}`); + console.log(` Path: ${binaryPath || chalk.dim('not found')}`); + if (!binaryPath) { + console.log(` Hint: ${chalk.dim(prereq.installHint)}`); + } + } +} + // --------------------------------------------------------------------------- // Installation strategies // --------------------------------------------------------------------------- @@ -401,6 +439,7 @@ export async function installCommand(options: InstallOptions): Promise { console.log(chalk.yellow('No AI coding tools detected on this machine.')); console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode')); } + printSystemPrereqs(); return; } @@ -473,4 +512,6 @@ export async function installCommand(options: InstallOptions): Promise { } else { console.log(chalk.dim('All tools already up to date.')); } + + printSystemPrereqs(); } diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts new file mode 100644 index 0000000..92f48a6 --- /dev/null +++ b/src/commands/stop.test.ts @@ -0,0 +1,84 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), + loadSession: vi.fn(), + stopRecording: vi.fn(), + closeBrowser: vi.fn(), + getConsoleErrors: vi.fn(), + getConsoleOutput: vi.fn(), + getConsoleOutputJson: vi.fn(), + writeViewer: vi.fn(), + extractServerErrors: vi.fn(), + loadSessionLog: vi.fn(), + estimateTokenUsage: vi.fn(), + generateStoryboardArtifact: vi.fn(), +})); + +vi.mock('../utils/config.js', () => ({ loadConfig: mocks.loadConfig })); +vi.mock('../session/state.js', () => ({ loadSession: mocks.loadSession, clearSession: vi.fn() })); +vi.mock('../browser/capture.js', () => ({ stopRecording: mocks.stopRecording })); +vi.mock('../browser/session.js', () => ({ + closeBrowser: mocks.closeBrowser, + getConsoleErrors: mocks.getConsoleErrors, + getConsoleOutput: mocks.getConsoleOutput, + getConsoleOutputJson: mocks.getConsoleOutputJson, +})); +vi.mock('../artifacts/viewer.js', () => ({ writeViewer: mocks.writeViewer })); +vi.mock('../utils/error-patterns.js', () => ({ extractServerErrors: mocks.extractServerErrors })); +vi.mock('../commands/exec.js', () => ({ loadSessionLog: mocks.loadSessionLog })); +vi.mock('../utils/token-usage.js', () => ({ estimateTokenUsage: mocks.estimateTokenUsage })); +vi.mock('../artifacts/storyboard.js', () => ({ generateStoryboardArtifact: mocks.generateStoryboardArtifact })); + +import { stopCommand } from './stop.js'; + +describe('stopCommand storyboard mode', () => { + const root = '/tmp/proofshot-stop'; + const sessionDir = path.join(root, 'session'); + + beforeEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.mkdirSync(sessionDir, { recursive: true }); + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + mocks.loadConfig.mockReturnValue({ output: root, browser: { configPath: null } }); + mocks.loadSession.mockReturnValue({ + startedAt: '2026-04-14T00:00:00.000Z', + sessionDir, + sessionName: 'proofshot-test', + recordingActive: false, + videoPath: path.join(sessionDir, 'session.webm'), + serverErrorLog: path.join(sessionDir, 'server.log'), + serverCommand: 'npm run dev', + port: 3000, + description: 'storyboard test', + viewport: { width: 1280, height: 720 }, + }); + mocks.getConsoleErrors.mockReturnValue('No errors'); + mocks.getConsoleOutput.mockReturnValue(''); + mocks.getConsoleOutputJson.mockReturnValue([]); + mocks.writeViewer.mockReturnValue(path.join(sessionDir, 'viewer.html')); + mocks.extractServerErrors.mockReturnValue([]); + mocks.loadSessionLog.mockReturnValue([]); + mocks.estimateTokenUsage.mockReturnValue(null); + mocks.stopRecording.mockImplementation(() => {}); + mocks.closeBrowser.mockImplementation(() => {}); + mocks.generateStoryboardArtifact.mockReturnValue({ + imagePath: path.join(sessionDir, 'storyboard.png'), + jsonPath: path.join(sessionDir, 'storyboard-scenes.json'), + }); + }); + + it('keeps storyboard generation opt-in', async () => { + await stopCommand({ noClose: true, storyboard: true }); + expect(mocks.generateStoryboardArtifact).toHaveBeenCalledWith({ inputDir: sessionDir }); + + mocks.generateStoryboardArtifact.mockClear(); + await stopCommand({ noClose: true }); + expect(mocks.generateStoryboardArtifact).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 2600ed3..e0c21fe 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -8,6 +8,7 @@ import { closeBrowser, getConsoleErrors, getConsoleOutput, getConsoleOutputJson import { stopRecording } from '../browser/capture.js'; import { loadSession, clearSession } from '../session/state.js'; import { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js'; +import { generateStoryboardArtifact } from '../artifacts/storyboard.js'; import { extractServerErrors } from '../utils/error-patterns.js'; import { loadSessionLog } from './exec.js'; import { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js'; @@ -51,6 +52,7 @@ function parseTimestampedServerLog( interface StopOptions { noClose?: boolean; + storyboard?: boolean; } export async function stopCommand(options: StopOptions): Promise { @@ -208,6 +210,20 @@ export async function stopCommand(options: StopOptions): Promise { tokenUsage, }); + let storyboardImagePath: string | null = null; + let storyboardJsonPath: string | null = null; + if (options.storyboard) { + try { + const storyboardResult = generateStoryboardArtifact({ + inputDir: sessionDir, + }); + storyboardImagePath = storyboardResult.imagePath ?? null; + storyboardJsonPath = storyboardResult.jsonPath ?? null; + } catch (error: any) { + console.log(chalk.dim(`Storyboard failed: ${error?.message || String(error)}`)); + } + } + // Step 8: Clear session state clearSession(outputDir); @@ -226,6 +242,10 @@ export async function stopCommand(options: StopOptions): Promise { } else { console.log(chalk.dim('Tip: Use "proofshot exec" instead of "agent-browser" to get an interactive timeline viewer.')); } + if (storyboardImagePath && storyboardJsonPath) { + console.log(`🖼️ Storyboard: ${chalk.dim(storyboardImagePath)}`); + console.log(`🧩 Scenes: ${chalk.dim(storyboardJsonPath)}`); + } console.log(''); console.log( `Console errors: ${consoleErrorCount === 0 ? chalk.green('0') : chalk.red(String(consoleErrorCount))}`, From 870111947558c6f9eb1a06169a6d2fc12be0e6e2 Mon Sep 17 00:00:00 2001 From: justinTM <9123665+justinTM@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:42:41 -0700 Subject: [PATCH 2/3] feat: improve storyboard proof and PR comments --- README.md | 2 +- src/artifacts/pr-format.ts | 29 +++++++++++++--- src/artifacts/storyboard.test.ts | 17 +++++++--- src/artifacts/storyboard.ts | 58 +++++++++++++++++++++++++++++--- src/commands/pr.ts | 27 +++++++++------ src/commands/stop.ts | 14 ++++---- src/utils/error-patterns.test.ts | 10 ++++++ src/utils/error-patterns.ts | 1 + src/utils/github.test.ts | 54 ++++++++++++++++++++++++++++- src/utils/github.ts | 48 ++++++++++++++++++++++++++ src/utils/process.test.ts | 14 ++++++++ src/utils/process.ts | 18 +++++++++- src/utils/skills.ts | 12 +++---- 13 files changed, 265 insertions(+), 39 deletions(-) create mode 100644 src/utils/error-patterns.test.ts diff --git a/README.md b/README.md index 5722f0c..d4d4272 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ proofshot diff --baseline ./previous-artifacts ### `proofshot pr` -Upload session artifacts to GitHub and post a verification comment on the PR. Finds all sessions recorded on the current branch, uploads screenshots and video, and posts a formatted comment with embedded screenshots. +Upload session artifacts to GitHub and post a verification comment on the PR. Finds all sessions recorded on the current branch, uploads screenshots, video, and storyboard contact sheets when present, and posts a formatted comment with embedded proof artifacts. ```bash proofshot pr # Auto-detect PR from current branch diff --git a/src/artifacts/pr-format.ts b/src/artifacts/pr-format.ts index fd1660b..1ffcfbb 100644 --- a/src/artifacts/pr-format.ts +++ b/src/artifacts/pr-format.ts @@ -100,18 +100,29 @@ export function formatPRComment(data: PRCommentData): string { } // Screenshots - if (data.screenshots.size > 0) { + const screenshotEntries = [...data.screenshots.entries()]; + const storyboardEntry = screenshotEntries.find(([filename]) => isStoryboardFile(filename)); + const regularScreenshots = screenshotEntries.filter(([filename]) => !isStoryboardFile(filename)); + + if (storyboardEntry) { + const [filename, url] = storyboardEntry; + const label = storyboardLabel(filename); + md += `### Storyboard\n\n`; + md += `![${label}](${url})\n\n`; + } + + if (regularScreenshots.length > 0) { md += `### Screenshots\n\n`; - if (data.screenshots.size <= 3) { - for (const [filename, url] of data.screenshots) { + if (regularScreenshots.length <= 3) { + for (const [filename, url] of regularScreenshots) { const label = filename.replace(/\.png$/, '').replace(/^step-/, ''); md += `**${label}**\n\n`; md += `![${label}](${url})\n\n`; } } else { - md += `
\nView ${data.screenshots.size} screenshots\n\n`; - for (const [filename, url] of data.screenshots) { + md += `
\nView ${regularScreenshots.length} screenshots\n\n`; + for (const [filename, url] of regularScreenshots) { const label = filename.replace(/\.png$/, '').replace(/^step-/, ''); md += `**${label}**\n\n![${label}](${url})\n\n`; } @@ -132,3 +143,11 @@ export function formatPRComment(data: PRCommentData): string { return md; } + +function isStoryboardFile(filename: string): boolean { + return path.basename(filename).toLowerCase().startsWith('storyboard'); +} + +function storyboardLabel(filename: string): string { + return path.basename(filename).replace(/\.png$/i, ''); +} diff --git a/src/artifacts/storyboard.test.ts b/src/artifacts/storyboard.test.ts index bcd3b23..f01d04d 100644 --- a/src/artifacts/storyboard.test.ts +++ b/src/artifacts/storyboard.test.ts @@ -21,7 +21,9 @@ import { generateStoryboardArtifact } from './storyboard.js'; describe('generateStoryboardArtifact', () => { beforeEach(() => { vi.restoreAllMocks(); - findExecutablePathMock.mockReturnValue('/usr/bin/ffmpeg'); + findExecutablePathMock.mockImplementation((command: string) => + command === 'ffprobe' ? '/usr/bin/ffprobe' : '/usr/bin/ffmpeg', + ); spawnSyncMock.mockReset(); }); @@ -80,6 +82,10 @@ describe('generateStoryboardArtifact', () => { return { stdout: '', stderr: '', status: 0, error: undefined } as never; } + if (args.includes('-show_entries')) { + return { stdout: '40.0\n', stderr: '', status: 0, error: undefined } as never; + } + fs.writeFileSync(outputPath, 'png'); return { stdout: '', stderr: '', status: 0, error: undefined } as never; }); @@ -88,9 +94,12 @@ describe('generateStoryboardArtifact', () => { const storyboard = JSON.parse(fs.readFileSync(path.join(sessionDir, 'storyboard-scenes.json'), 'utf8')); expect(storyboard.mode).toBe('fallback'); - expect(storyboard.scenes).toEqual([]); - expect(spawnSyncMock.mock.calls).toHaveLength(2); - expect(spawnSyncMock.mock.calls[1][1].join(' ')).not.toContain('select=gt(scene'); + expect(storyboard.scenes).toHaveLength(20); + expect(storyboard.scenes[0]).toEqual({ label: 'sample-001', timeSec: 1 }); + expect(storyboard.scenes[19]).toEqual({ label: 'sample-020', timeSec: 39 }); + expect(spawnSyncMock.mock.calls).toHaveLength(3); + expect(spawnSyncMock.mock.calls[2][1].join(' ')).toContain('fps=0.5'); + expect(spawnSyncMock.mock.calls[2][1].join(' ')).not.toContain('select=gt(scene'); expect(logSpy.mock.calls.flat().join('\n')).toContain('Storyboard fallback'); }); diff --git a/src/artifacts/storyboard.ts b/src/artifacts/storyboard.ts index 63a6beb..95f263a 100644 --- a/src/artifacts/storyboard.ts +++ b/src/artifacts/storyboard.ts @@ -38,6 +38,15 @@ function scenesFromTimes(times: number[]) { return times.map((timeSec, i) => ({ label: `scene-${String(i + 1).padStart(3, '0')}`, timeSec })); } +function sampledFramesFromDuration(durationSec: number): { label: string; timeSec: number }[] { + if (!Number.isFinite(durationSec) || durationSec <= 0) return []; + + return Array.from({ length: MAX_SCENES }, (_, i) => ({ + label: `sample-${String(i + 1).padStart(3, '0')}`, + timeSec: Number(((durationSec * (i + 0.5)) / MAX_SCENES).toFixed(1)), + })); +} + function runFfmpeg(ffmpeg: string, args: string[]): string { const result = spawnSync(ffmpeg, args, { encoding: 'utf-8', @@ -50,13 +59,37 @@ function runFfmpeg(ffmpeg: string, args: string[]): string { return output; } -function renderFilter(threshold: number, grid: string, width: number, useSceneSelect: boolean): string { +function renderFilter( + threshold: number, + grid: string, + width: number, + options: { useSceneSelect: boolean; fps?: number | null }, +): string { const cols = Number.parseInt(grid, 10) || 4; const thumbWidth = Math.max(1, Math.floor(width / cols)); - const prefix = useSceneSelect ? `select='gt(scene\\,${threshold})',` : ''; + const fpsPrefix = options.fps ? `fps=${options.fps},` : ''; + const prefix = options.useSceneSelect ? `select='gt(scene\\,${threshold})',` : fpsPrefix; return `${prefix}scale=${thumbWidth}:-1:flags=lanczos,tile=${grid}`; } +function readVideoDuration(ffprobe: string, videoPath: string): number | null { + try { + const output = runFfmpeg(ffprobe, [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + videoPath, + ]).trim(); + const duration = Number(output.split(/\r?\n/)[0]); + return Number.isFinite(duration) && duration > 0 ? duration : null; + } catch { + return null; + } +} + export function generateStoryboardArtifact(options: StoryboardOptions): { imagePath: string | null; jsonPath: string | null; @@ -66,6 +99,7 @@ export function generateStoryboardArtifact(options: StoryboardOptions): { console.log(chalk.dim('Storyboard unavailable: install ffmpeg to generate storyboard artifacts.')); return { imagePath: null, jsonPath: null }; } + const ffprobe = findExecutablePath('ffprobe'); const inputDir = path.resolve(options.inputDir); const videoPath = ['session.webm', 'session.mp4', 'session.mov'] @@ -79,8 +113,12 @@ export function generateStoryboardArtifact(options: StoryboardOptions): { const grid = options.grid ?? DEFAULT_GRID; const width = options.width ?? DEFAULT_WIDTH; const sceneOutput = runFfmpeg(ffmpeg, ['-hide_banner', '-i', videoPath, '-vf', `select='gt(scene\\,${threshold})',showinfo`, '-an', '-f', 'null', '-']); - const scenes = scenesFromTimes(parseSceneTimes(sceneOutput)); + let scenes = scenesFromTimes(parseSceneTimes(sceneOutput)); let mode: 'scene' | 'fallback' = scenes.length >= MIN_SCENES_FOR_SCENE_MODE ? 'scene' : 'fallback'; + const durationSec = mode === 'fallback' && ffprobe ? readVideoDuration(ffprobe, videoPath) : null; + if (mode === 'fallback' && durationSec) { + scenes = sampledFramesFromDuration(durationSec); + } const artifactPath = path.join(inputDir, 'storyboard-scenes.json'); const writeArtifact = (): void => { @@ -103,7 +141,19 @@ export function generateStoryboardArtifact(options: StoryboardOptions): { }; const render = (useSceneSelect: boolean): void => { - runFfmpeg(ffmpeg, ['-hide_banner', '-i', videoPath, '-vf', renderFilter(threshold, grid, width, useSceneSelect), '-frames:v', '1', outputPath]); + runFfmpeg(ffmpeg, [ + '-hide_banner', + '-i', + videoPath, + '-vf', + renderFilter(threshold, grid, width, { + useSceneSelect, + fps: !useSceneSelect && durationSec ? Number((MAX_SCENES / durationSec).toFixed(3)) : null, + }), + '-frames:v', + '1', + outputPath, + ]); }; if (mode === 'scene') { diff --git a/src/commands/pr.ts b/src/commands/pr.ts index 85cc7b4..48c60c6 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { execSync } from 'child_process'; import chalk from 'chalk'; import { loadConfig } from '../utils/config.js'; +import { findExecutablePath, runCommand } from '../utils/process.js'; import { type GitHubUploadProvider, getGitHubToken, @@ -113,17 +114,18 @@ export async function prCommand(options: PROptions): Promise { if (fs.existsSync(mp4Path)) { videoPath = mp4Path; } else { - try { - execSync('ffmpeg -version', { stdio: 'pipe' }); + const ffmpeg = findExecutablePath('ffmpeg'); + if (!ffmpeg) { + console.log(chalk.dim('ffmpeg not available — uploading .webm directly')); + } else { console.log(chalk.dim('Converting video to .mp4...')); - execSync( - `ffmpeg -i "${videoPath}" -c:v libx264 -preset fast -crf 23 -an "${mp4Path}"`, - { stdio: 'pipe', timeout: 120000 }, + runCommand( + ffmpeg, + ['-i', videoPath, '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-an', mp4Path], + { timeout: 120000 }, ); videoPath = mp4Path; console.log(chalk.green('✓') + ' Video converted to .mp4'); - } catch { - console.log(chalk.dim('ffmpeg not available — uploading .webm directly')); } } } @@ -249,9 +251,14 @@ export async function prCommand(options: PROptions): Promise { console.log(''); console.log(chalk.green.bold(`✅ Posted ProofShot verification to PR #${prNumber}`)); - console.log( - chalk.dim(` ${screenshotMap.size} screenshot(s), ${video ? '1 video' : 'no video'}`), - ); + const regularScreenshotCount = [...screenshotMap.keys()].filter( + (label) => !path.basename(label).toLowerCase().startsWith('storyboard'), + ).length; + const storyboardCount = screenshotMap.size - regularScreenshotCount; + const artifactSummary = `${regularScreenshotCount} screenshot(s)${ + storyboardCount > 0 ? `, ${storyboardCount} storyboard` : '' + }, ${video ? '1 video' : 'no video'}`; + console.log(chalk.dim(` ${artifactSummary}`)); } /** diff --git a/src/commands/stop.ts b/src/commands/stop.ts index e0c21fe..77487e4 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -1,6 +1,5 @@ import * as fs from 'fs'; import * as path from 'path'; -import { execSync } from 'child_process'; import chalk from 'chalk'; import { loadConfig } from '../utils/config.js'; import { setAgentBrowserDefaults } from '../utils/exec.js'; @@ -10,6 +9,7 @@ import { loadSession, clearSession } from '../session/state.js'; import { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js'; import { generateStoryboardArtifact } from '../artifacts/storyboard.js'; import { extractServerErrors } from '../utils/error-patterns.js'; +import { findExecutablePath, runCommand } from '../utils/process.js'; import { loadSessionLog } from './exec.js'; import { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js'; @@ -426,9 +426,8 @@ function trimVideo( if (trimEndSec - trimStartSec < 5) return 0; // Check if ffmpeg is available - try { - execSync('ffmpeg -version', { stdio: 'pipe' }); - } catch { + const ffmpeg = findExecutablePath('ffmpeg'); + if (!ffmpeg) { console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.')); return 0; } @@ -443,9 +442,10 @@ function trimVideo( // Rename original to -raw fs.renameSync(videoPath, rawPath); - execSync( - `ffmpeg -i "${rawPath}" -ss ${trimStartSec.toFixed(2)} -to ${trimEndSec.toFixed(2)} -c copy "${videoPath}"`, - { stdio: 'pipe', timeout: 60000 }, + runCommand( + ffmpeg, + ['-i', rawPath, '-ss', trimStartSec.toFixed(2), '-to', trimEndSec.toFixed(2), '-c', 'copy', videoPath], + { timeout: 60000 }, ); // Remove raw file on success diff --git a/src/utils/error-patterns.test.ts b/src/utils/error-patterns.test.ts new file mode 100644 index 0000000..344401a --- /dev/null +++ b/src/utils/error-patterns.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { extractServerErrors } from './error-patterns.js'; + +describe('extractServerErrors', () => { + it('ignores favicon 404 noise', () => { + const errors = extractServerErrors('[server] Error: GET /favicon.ico returned 404'); + + expect(errors).toEqual([]); + }); +}); diff --git a/src/utils/error-patterns.ts b/src/utils/error-patterns.ts index f37dbae..e51f356 100644 --- a/src/utils/error-patterns.ts +++ b/src/utils/error-patterns.ts @@ -118,6 +118,7 @@ export function extractServerErrors(log: string): string[] { return log.split('\n').filter((line) => { const trimmed = line.trim(); if (!trimmed) return false; + if (/GET\s+\/favicon\.ico\s+returned\s+404/i.test(trimmed)) return false; return allPatterns.some((p) => p.test(trimmed)); }); } diff --git a/src/utils/github.test.ts b/src/utils/github.test.ts index a689649..0f4ca86 100644 --- a/src/utils/github.test.ts +++ b/src/utils/github.test.ts @@ -3,7 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { formatPRComment } from '../artifacts/pr-format.js'; -import { getGitHubToken, uploadAsset } from './github.js'; +import { getGitHubToken, parseGitHubRemoteUrl, uploadAsset } from './github.js'; describe('getGitHubToken', () => { afterEach(() => { @@ -18,6 +18,19 @@ describe('getGitHubToken', () => { }); }); +describe('parseGitHubRemoteUrl', () => { + it('parses common GitHub SSH and HTTPS remote URLs', () => { + expect(parseGitHubRemoteUrl('git@github.com:AmElmo/proofshot.git')).toEqual({ + owner: 'AmElmo', + repo: 'proofshot', + }); + expect(parseGitHubRemoteUrl('https://github.com/justinTM/proofshot.git')).toEqual({ + owner: 'justinTM', + repo: 'proofshot', + }); + }); +}); + describe('uploadAsset', () => { afterEach(() => { vi.restoreAllMocks(); @@ -61,4 +74,43 @@ describe('formatPRComment', () => { expect(body).toContain('[Session recording](https://example.com/session.mp4)'); expect(body).not.toContain('\nhttps://example.com/session.mp4\n'); }); + + it('renders GitHub attachment videos as embeds', () => { + const body = formatPRComment({ + description: 'Verify checkout', + sessionCount: 1, + screenshots: new Map(), + video: { + url: 'https://example.com/session.mp4', + renderMode: 'embed', + }, + errorCount: 0, + branch: 'feature/test', + commitSha: 'abcdef123456', + }); + + expect(body).toContain('\nhttps://example.com/session.mp4\n'); + expect(body).not.toContain('[Session recording](https://example.com/session.mp4)'); + }); + + it('renders storyboard images in their own section', () => { + const body = formatPRComment({ + description: 'Verify checkout', + sessionCount: 1, + screenshots: new Map([ + ['step-dashboard.png', 'https://example.com/dashboard.png'], + ['storyboard.png', 'https://example.com/storyboard.png'], + ]), + video: null, + errorCount: 0, + branch: 'feature/test', + commitSha: 'abcdef123456', + }); + + expect(body).toContain('### Storyboard'); + expect(body).toContain('![storyboard](https://example.com/storyboard.png)'); + expect(body).toContain('### Screenshots'); + expect(body).toContain('![dashboard](https://example.com/dashboard.png)'); + expect(body).not.toContain('View 2 screenshots'); + }); }); diff --git a/src/utils/github.ts b/src/utils/github.ts index 730ebf8..88b14d9 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -67,6 +67,12 @@ export function getGitHubToken(): string { * Get the current repo's owner, name, and numeric ID. */ export async function getRepoInfo(token: string): Promise { + const trackedRemoteUrl = getTrackedRemoteUrl(); + const trackedRepo = trackedRemoteUrl ? parseGitHubRemoteUrl(trackedRemoteUrl) : null; + if (trackedRepo) { + return getRepoInfoByName(trackedRepo.owner, trackedRepo.repo, token); + } + let nwo: string; try { nwo = execSync('gh repo view --json nameWithOwner -q .nameWithOwner', { @@ -82,6 +88,48 @@ export async function getRepoInfo(token: string): Promise { const [owner, repo] = nwo.split('/'); + return getRepoInfoByName(owner, repo, token); +} + +function getTrackedRemoteUrl(): string | null { + try { + const upstream = execSync('git rev-parse --abbrev-ref --symbolic-full-name @{u}', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + if (!upstream) return null; + + const remoteName = upstream.split('/', 1)[0]; + return execSync(`git remote get-url ${remoteName}`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { + return null; + } +} + +export function parseGitHubRemoteUrl(url: string): { owner: string; repo: string } | null { + const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2] }; + } + + const httpsMatch = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/)?$/); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + + const sshUrlMatch = url.match(/^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/); + if (sshUrlMatch) { + return { owner: sshUrlMatch[1], repo: sshUrlMatch[2] }; + } + + return null; +} + +async function getRepoInfoByName(owner: string, repo: string, token: string): Promise { const repoResponse = await githubApi<{ id: number; default_branch: string; diff --git a/src/utils/process.test.ts b/src/utils/process.test.ts index 8d73db5..ac829d2 100644 --- a/src/utils/process.test.ts +++ b/src/utils/process.test.ts @@ -4,6 +4,7 @@ import { getShellExecutable, parseWindowsNetstatOutput, readCommandVersion, + runCommand, } from './process.js'; describe('getShellExecutable', () => { @@ -62,3 +63,16 @@ describe('readCommandVersion', () => { expect(execSpy).toHaveBeenCalledWith('ffmpeg --version', expect.any(Object)); }); }); + +describe('runCommand', () => { + it('passes arguments without shell parsing', () => { + const output = runCommand(process.execPath, [ + '-e', + 'process.stdout.write(process.argv.slice(1).join("|"))', + 'one two', + 'three', + ]); + + expect(output).toBe('one two|three'); + }); +}); diff --git a/src/utils/process.ts b/src/utils/process.ts index 7d13cb5..6216282 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -1,4 +1,4 @@ -import { execSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process'; +import { execSync, spawn, spawnSync, type ChildProcess, type SpawnOptions, type SpawnSyncOptions } from 'child_process'; type ExecSyncLike = typeof execSync; @@ -23,6 +23,22 @@ export function spawnShellCommand( }); } +export function runCommand( + command: string, + args: string[] = [], + options: SpawnSyncOptions = {}, +): string { + const result = spawnSync(command, args, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim(); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(output || `Command failed: ${command}`); + return output; +} + export function parseWindowsNetstatOutput(output: string, port: number): number[] { const pids = new Set(); diff --git a/src/utils/skills.ts b/src/utils/skills.ts index 10db083..565b856 100644 --- a/src/utils/skills.ts +++ b/src/utils/skills.ts @@ -80,7 +80,7 @@ proofshot stop \`\`\` This stops recording, collects console + server errors, and generates -a SUMMARY.md with video, screenshots, and error report. +a SUMMARY.md with video, screenshots, optional storyboard contact sheet, and error report. ### Step 4 (optional): Post proof to the PR @@ -89,7 +89,7 @@ proofshot pr # Auto-detect PR from current branch proofshot pr 42 # Target a specific PR number \`\`\` -This uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \`gh\` CLI to be authenticated. +This uploads screenshots, video, and any storyboard contact sheet to GitHub and posts a formatted comment on the PR with inline media. Requires \`gh\` CLI to be authenticated. Default upload mode uses the official GitHub contents API on a \`proofshot-artifacts\` branch. For GitHub-hosted attachment URLs, use \`proofshot pr --upload-provider github-web-attachments\`. ## Tips @@ -112,8 +112,8 @@ After modifying UI files, visually verify changes with this workflow: 1. Start session: \`proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"\` If the server is already running, omit --run. 2. Drive browser: Use \`proofshot exec\` commands to navigate, click, fill forms, and take screenshots -3. Stop session: \`proofshot stop\` to bundle video + screenshots + error report -4. (Optional) Post to PR: \`proofshot pr\` to upload proof to the GitHub PR +3. Stop session: \`proofshot stop\` to bundle video + screenshots + error report, or add \`--storyboard\` for a contact sheet +4. (Optional) Post to PR: \`proofshot pr\` to upload proof to the GitHub PR, including storyboard output when present Default provider uses the official contents API. Use \`--upload-provider github-web-attachments\` only if you specifically want GitHub attachment URLs. Key proofshot exec commands: @@ -132,8 +132,8 @@ After building or modifying UI features, verify with this workflow: 1. Start: \`proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"\` If the server is already running, omit --run. 2. Test: Use \`proofshot exec\` to navigate, click, fill forms, take screenshots -3. Stop: \`proofshot stop\` — bundles video, screenshots, and error report -4. (Optional) Post to PR: \`proofshot pr\` — uploads proof to the GitHub PR +3. Stop: \`proofshot stop\` — bundles video, screenshots, error report, and optional storyboard contact sheet +4. (Optional) Post to PR: \`proofshot pr\` — uploads proof to the GitHub PR, including storyboard output when present Default provider uses the official contents API. Use \`--upload-provider github-web-attachments\` only if you specifically want GitHub attachment URLs. Key proofshot exec commands: From 30a8351f8fe62755e762d663e9a3128ece280c5c Mon Sep 17 00:00:00 2001 From: justinTM <9123665+justinTM@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:50:30 -0700 Subject: [PATCH 3/3] fix: validate storyboard trim outputs --- src/commands/stop.test.ts | 43 ++++++++++++++++++++ src/commands/stop.ts | 84 ++++++++++++++++++++++++++++++++++----- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts index 92f48a6..33c9f90 100644 --- a/src/commands/stop.test.ts +++ b/src/commands/stop.test.ts @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ loadSessionLog: vi.fn(), estimateTokenUsage: vi.fn(), generateStoryboardArtifact: vi.fn(), + findExecutablePath: vi.fn(), + runCommand: vi.fn(), })); vi.mock('../utils/config.js', () => ({ loadConfig: mocks.loadConfig })); @@ -31,6 +33,10 @@ vi.mock('../utils/error-patterns.js', () => ({ extractServerErrors: mocks.extrac vi.mock('../commands/exec.js', () => ({ loadSessionLog: mocks.loadSessionLog })); vi.mock('../utils/token-usage.js', () => ({ estimateTokenUsage: mocks.estimateTokenUsage })); vi.mock('../artifacts/storyboard.js', () => ({ generateStoryboardArtifact: mocks.generateStoryboardArtifact })); +vi.mock('../utils/process.js', () => ({ + findExecutablePath: mocks.findExecutablePath, + runCommand: mocks.runCommand, +})); import { stopCommand } from './stop.js'; @@ -58,6 +64,7 @@ describe('stopCommand storyboard mode', () => { description: 'storyboard test', viewport: { width: 1280, height: 720 }, }); + fs.writeFileSync(path.join(sessionDir, 'session.webm'), 'webm'); mocks.getConsoleErrors.mockReturnValue('No errors'); mocks.getConsoleOutput.mockReturnValue(''); mocks.getConsoleOutputJson.mockReturnValue([]); @@ -67,6 +74,18 @@ describe('stopCommand storyboard mode', () => { mocks.estimateTokenUsage.mockReturnValue(null); mocks.stopRecording.mockImplementation(() => {}); mocks.closeBrowser.mockImplementation(() => {}); + mocks.findExecutablePath.mockImplementation((command: string) => + command === 'ffmpeg' ? '/usr/bin/ffmpeg' : null, + ); + mocks.runCommand.mockImplementation((command: string, args: string[]) => { + const joined = args.join(' '); + if (joined.includes('libvpx-vp9')) return 'reencoded'; + if (joined.includes('-v error -i')) { + if (joined.includes('libvpx-vp9')) return ''; + throw new Error('invalid webm'); + } + return 'trimmed'; + }); mocks.generateStoryboardArtifact.mockReturnValue({ imagePath: path.join(sessionDir, 'storyboard.png'), jsonPath: path.join(sessionDir, 'storyboard-scenes.json'), @@ -81,4 +100,28 @@ describe('stopCommand storyboard mode', () => { await stopCommand({ noClose: true }); expect(mocks.generateStoryboardArtifact).not.toHaveBeenCalled(); }); + + it('re-encodes the trim when the copy cut is invalid', async () => { + const trimCalls: string[] = []; + mocks.loadSessionLog.mockReturnValue([ + { relativeTimeSec: 8 }, + { relativeTimeSec: 19 }, + ]); + mocks.runCommand.mockImplementation((command: string, args: string[]) => { + const joined = args.join(' '); + trimCalls.push(joined); + if (joined.includes('-v error -i')) { + if (trimCalls.some((call) => call.includes('libvpx-vp9'))) { + return ''; + } + throw new Error('invalid webm'); + } + return 'ok'; + }); + + await stopCommand({ noClose: true, storyboard: false }); + + expect(trimCalls.some((call) => call.includes('-c copy'))).toBe(true); + expect(trimCalls.some((call) => call.includes('libvpx-vp9'))).toBe(true); + }); }); diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 77487e4..3555a93 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -442,17 +442,42 @@ function trimVideo( // Rename original to -raw fs.renameSync(videoPath, rawPath); - runCommand( - ffmpeg, - ['-i', rawPath, '-ss', trimStartSec.toFixed(2), '-to', trimEndSec.toFixed(2), '-c', 'copy', videoPath], - { timeout: 60000 }, - ); + if ( + tryTrimCommand(ffmpeg, rawPath, videoPath, trimStartSec, trimEndSec, ['-c', 'copy']) && + validateTrimmedVideo(ffmpeg, videoPath) + ) { + fs.unlinkSync(rawPath); + const trimmedDuration = Math.round(trimEndSec - trimStartSec); + console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`)); + return trimStartSec; + } + + removeFile(videoPath); + if ( + tryTrimCommand(ffmpeg, rawPath, videoPath, trimStartSec, trimEndSec, [ + '-c:v', + 'libvpx-vp9', + '-crf', + '33', + '-b:v', + '0', + '-c:a', + 'libopus', + ]) && + validateTrimmedVideo(ffmpeg, videoPath) + ) { + fs.unlinkSync(rawPath); + const trimmedDuration = Math.round(trimEndSec - trimStartSec); + console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (re-encoded dead time)`)); + return trimStartSec; + } - // Remove raw file on success - fs.unlinkSync(rawPath); - const trimmedDuration = Math.round(trimEndSec - trimStartSec); - console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`)); - return trimStartSec; + removeFile(videoPath); + if (fs.existsSync(rawPath)) { + fs.renameSync(rawPath, videoPath); + } + console.log(chalk.dim('Video trimming failed, keeping original')); + return 0; } catch { // Restore original if trimming failed if (fs.existsSync(rawPath)) { @@ -466,3 +491,42 @@ function trimVideo( return 0; } } + +function tryTrimCommand( + ffmpeg: string, + rawPath: string, + videoPath: string, + trimStartSec: number, + trimEndSec: number, + extraArgs: string[], +): boolean { + try { + runCommand( + ffmpeg, + ['-i', rawPath, '-ss', trimStartSec.toFixed(2), '-to', trimEndSec.toFixed(2), ...extraArgs, videoPath], + { timeout: 60000 }, + ); + return true; + } catch { + return false; + } +} + +function validateTrimmedVideo(ffmpeg: string, videoPath: string): boolean { + try { + runCommand(ffmpeg, ['-v', 'error', '-i', videoPath, '-f', 'null', '-'], { timeout: 60000 }); + return true; + } catch { + return false; + } +} + +function removeFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch { + // Ignore cleanup failures; trimVideo will restore the raw file if needed. + } +}