From e650a49edc54f37aa44f3853d02531c295de1802 Mon Sep 17 00:00:00 2001 From: Jacqueline O'Donnell Date: Fri, 5 Jun 2026 22:43:46 -0700 Subject: [PATCH] Release screenstage 0.2.3 --- CHANGELOG.md | 14 +- RELEASING.md | 15 +- docs/agent-tooling-plan.md | 549 +++---------------------------------- docs/authoring.md | 2 +- docs/cli-contract.md | 3 + docs/config-reference.md | 2 + package-lock.json | 13 +- package.json | 2 +- readme.md | 17 +- src/cli.ts | 30 +- src/config.ts | 12 +- src/record.ts | 250 +++++++++-------- src/run.ts | 284 +++++++++++-------- 13 files changed, 420 insertions(+), 773 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08eb63d..e31f0cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,21 @@ All notable changes to this project will be documented in this file. -The format is based on Keep a Changelog, and this project aims to follow Semantic Versioning once public releases start. +The format is based on Keep a Changelog, and this project aims to follow Semantic Versioning for public releases. ## [Unreleased] -- Nothing unreleased yet. +Nothing unreleased yet. + +## [0.2.3] - 2026-06-06 + +- Added a `--browser-channel` CLI override so captures can use installed Chromium-family browsers like Chrome or Edge. +- Documented the FFmpeg, SVG rasterizer, and Playwright video dependencies needed for the quickstart. +- Cleaned up browser, context, and service teardown when capture setup fails before recording starts. + +## [0.2.2] - 2026-03-09 + +- Re-published package metadata for the release toolchain. ## [0.2.1] - 2026-03-09 diff --git a/RELEASING.md b/RELEASING.md index 57bd4f7..3aa1231 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,13 +1,14 @@ # Releasing -This repo is set up to be published as the `screenstage` npm package on npmjs.org, with a scoped GitHub Packages mirror published from tags. +This repo publishes the `screenstage` npm package on npmjs.org, with a scoped GitHub Packages mirror published from version tags. -## Before The First Public Release +## Release Setup -1. Confirm the npm package name is still available. -2. Configure npm trusted publishing for this repo and its `release.yml` workflow. -3. Make sure the public repo metadata in `package.json` matches the live GitHub repo. -4. Keep the release workflow on `Node >= 22.14.0` and `npm >= 11.5.1`, which npm currently requires for trusted publishing. +- The npm package name is `screenstage`. +- `package.json` points at `https://github.com/jodonnell24/screenstage`. +- The release workflow uses npm trusted publishing for npmjs.org. +- The release workflow also publishes a scoped GitHub Packages mirror. +- The workflow runs on Node 22.14.0 and upgrades npm before publishing. ## Local Release Checklist @@ -39,7 +40,7 @@ npm run pack:check git push --follow-tags ``` -6. Publish manually if you want to ship before using GitHub Actions: +6. Publish manually only if you intentionally want to bypass the tag workflow: ```bash npm publish --access public diff --git a/docs/agent-tooling-plan.md b/docs/agent-tooling-plan.md index 5e012a9..df301ca 100644 --- a/docs/agent-tooling-plan.md +++ b/docs/agent-tooling-plan.md @@ -1,533 +1,50 @@ -# Screenstage Agent Tooling Plan +# Screenstage Agent Integration Status -## Goal +This note records what is already implemented for agent-friendly use and what still belongs on the roadmap. It is intentionally not required reading for normal CLI usage; start with [for-agents.md](./for-agents.md) and [cli-contract.md](./cli-contract.md) for the supported integration surface. -Turn Screenstage from a strong human-operated CLI into a reliable tool that AI agents and vibe-coding workflows can use without guesswork. +## Current Position -The target outcome is not just "an AI can run the CLI." The target outcome is: +Screenstage is a browser demo capture engine with two supported workflows: -- an agent can discover the tool quickly -- an agent can choose the right workflow without reading the whole codebase -- an agent can run captures non-interactively -- an agent can inspect results through structured outputs -- an agent can recover from common failures -- the integration surface is stable enough to support Codex skills now and broader agent ecosystems later +- `run` for scripted, repeatable captures +- `record` for human-driven studio captures that still produce structured artifacts -## Product Positioning +The CLI is already usable from agent-assisted development loops where a local app, static page, or deployed URL needs to become a polished product demo video. -Screenstage should be framed as: +## Implemented -- a browser demo capture engine -- a polished rendering pipeline for product videos -- an agent-friendly automation primitive for generating app walkthroughs, launch demos, changelog clips, and onboarding footage +- Machine-readable `--json` mode for `run` and `record` +- Stable exit-code categories documented in [cli-contract.md](./cli-contract.md) +- Manifest-based artifact discovery for videos, posters, contact sheets, recordings, markers, and generated demo files +- Config overrides for output directories, headless/visible browser mode, and capture output shape +- A portable skill under [skills/screenstage/](../skills/screenstage/) with config and troubleshooting references +- Dedicated docs for authoring, configuration, CLI contract, and agent use -That positioning matters because agents do not need "video editing software." They need a dependable tool that accepts a target app plus capture intent and returns artifacts they can reason about. +## Still Worth Improving -## Core Recommendation +- Add more copyable agent examples that inspect `manifest.json` after a run +- Add intent-oriented presets such as `launch-demo`, `feature-walkthrough`, or `docs-snippet` +- Improve post-run summaries so humans and agents immediately see the final video, manifest, warnings, and likely next action +- Consider a narrow JavaScript API after the CLI contract has more real-world use +- Consider MCP only if cross-editor agent integration becomes a concrete goal -Build this in three layers, in order: +## Design Guidance -1. Harden the CLI contract for non-interactive, machine-friendly use. -2. Add a Codex skill that teaches the workflow and wraps the CLI. -3. Add a broader integration surface only after the CLI contract is stable. +Keep the CLI contract as the foundation. Skills, scripts, and future integrations should wrap the CLI and manifest rather than depending on ad hoc stdout parsing or internal module structure. -The likely broader surface is either: +When adding agent-facing features, prefer: -- a small JavaScript API if the main audience is developers embedding Screenstage in scripts -- an MCP server if the goal is cross-agent interoperability across editors and AI runtimes +- explicit flags over implicit prompts +- stable JSON fields over prose-only output +- actionable errors over generic failures +- examples that work against localhost and static HTML without extra services -Do not start with MCP. A weak core contract wrapped in MCP is still weak. The first job is making the existing CLI predictable for agents. +## Readiness Bar -## Why A Skill Is Worth Doing +Screenstage is agent-ready when these statements stay true: -A skill is the fastest path to immediate utility because it can teach an agent: - -- when to use `screenstage init` -- when to use `screenstage run` -- when to use `screenstage record` -- how to scaffold a config -- how to point Screenstage at a local app or static file -- how to inspect `manifest.json`, markers, and output assets -- how to debug common local failures around Playwright, FFmpeg, and dev servers - -This is especially useful in vibe-coding environments where the agent is already editing code, running a dev server, and automating the browser. Screenstage becomes the final output layer for "turn this finished flow into a polished demo." - -## Why A Skill Alone Is Not Enough - -A skill improves discoverability and workflow guidance, but it does not solve: - -- ambiguous CLI behavior -- brittle stdout parsing -- interactive prompts in agent runs -- unclear exit conditions -- unstable artifact locations -- poor machine-readable error reporting - -That is why the skill should be considered an orchestration layer, not the foundation. - -## Phase 1: Agent-Ready CLI Contract - -### Objective - -Make `screenstage` safe for unattended runs and easy for agents to integrate with from shell execution. - -### Changes - -#### 1. Add structured output mode - -Add a `--json` flag to every command where practical. - -Expected behavior: - -- emit newline-delimited JSON events or a single final JSON object -- include command name, config path, output directory, artifact paths, elapsed time, and status -- include structured errors with code, message, and likely remediation hint - -Recommended event model: - -- `command_started` -- `server_started` -- `browser_started` -- `capture_started` -- `capture_completed` -- `render_started` -- `render_completed` -- `artifacts_written` -- `command_completed` -- `command_failed` - -#### 2. Add explicit non-interactive controls - -Add flags that remove any ambiguity in agent contexts: - -- `--yes` or `--non-interactive` for `init` -- `--output-dir ` -- `--headless` -- `--no-open` -- `--browser visible|headless` if you want a more expressive alternative -- `--timeout ` - -If `record` cannot be fully non-interactive by design, document that clearly and make its machine limits explicit. - -#### 3. Define stable exit codes - -Suggested exit code categories: - -- `0`: success -- `2`: invalid config or arguments -- `3`: app target unavailable -- `4`: browser launch or Playwright failure -- `5`: capture failure -- `6`: FFmpeg/render failure -- `7`: dependency missing, such as `ffmpeg` - -Agents can make much better recovery decisions if these are stable. - -#### 4. Normalize artifact discovery - -Make sure every successful run writes a machine-readable manifest with stable keys. - -The existing `manifest.json` is already the right direction. Tighten it into a public contract: - -- input metadata -- resolved config summary -- output directory -- artifact file paths -- marker summary -- timing summary -- warnings -- failure details if partial output exists - -This manifest should be the canonical integration object. - -#### 5. Improve error messages for agent recovery - -Every major failure should include: - -- what failed -- why it likely failed -- what the caller should try next - -Examples: - -- app server did not become reachable before timeout -- Chromium runtime not installed -- `ffmpeg` missing from `PATH` -- selector in demo script no longer matched -- viewport or composition config invalid - -### Deliverables - -- CLI flags implemented -- structured JSON output implemented -- documented exit codes -- manifest contract documented -- README updated with agent examples - -### Acceptance Criteria - -- an agent can run `screenstage` without parsing human prose -- an agent can determine success or failure from exit code plus JSON -- an agent can locate all generated assets from the manifest alone - -## Phase 2: First-Class Agent Examples - -### Objective - -Give agents concrete examples they can copy instead of inferring usage from prose. - -### Changes - -#### 1. Add an "agent quickstart" example - -Create a minimal example showing: - -- local app startup -- config file -- scripted run -- output manifest inspection - -This should be the example a coding agent uses first. - -#### 2. Add an "AI workflow" section to the README - -Include examples like: - -```bash -screenstage run ./screenstage.config.mjs --json -``` - -and explain: - -- what files to inspect after success -- what flags matter in CI or local agent sessions -- how to retry or narrow down failures - -#### 3. Add canned task recipes - -Example recipes: - -- record a homepage walkthrough -- render a changelog clip for one new feature -- create a login-to-success-path demo against localhost -- turn a manual session into an editable generated demo - -### Deliverables - -- one or two copyable examples -- README section aimed at AI workflows -- sample JSON output in docs - -### Acceptance Criteria - -- a new agent can succeed by following examples instead of reverse-engineering the codebase - -## Phase 3: Codex Skill - -### Objective - -Make Screenstage immediately usable inside Codex-style environments through a small, targeted skill. - -### Recommended Skill Shape - -Proposed folder: - -`skills/screenstage/` - -Suggested contents: - -- `SKILL.md` -- `agents/openai.yaml` -- `references/config-patterns.md` -- `references/troubleshooting.md` - -Optional: - -- `scripts/inspect-manifest.mjs` -- `scripts/create-agent-config.mjs` - -### What The Skill Should Teach - -The `SKILL.md` should stay short and procedural. It should teach: - -- use `screenstage` when the user wants a polished browser demo video -- prefer `run` for scripted, repeatable captures -- prefer `record` when the user wants to perform the browser flow manually -- inspect `manifest.json` and output assets after completion -- if the project has a local app, start or reuse its dev server before capture -- if the output should be polished for launch or docs, use composition and output presets instead of raw capture - -### What Should Live In References - -`references/config-patterns.md`: - -- local app config pattern -- static HTML file pattern -- staging URL pattern -- common output presets -- composition guidance - -`references/troubleshooting.md`: - -- Playwright browser install issues -- `ffmpeg` availability -- local server timeouts -- selector drift -- headless versus visible run issues - -### Skill Trigger Language - -The skill description should trigger on requests like: - -- "record a product demo" -- "make a walkthrough video of this app" -- "capture a polished browser demo" -- "render a launch video from localhost" -- "turn this app flow into a marketing clip" - -### Deliverables - -- skill directory created -- concise `SKILL.md` -- references for config patterns and troubleshooting -- generated `agents/openai.yaml` - -### Acceptance Criteria - -- an agent inside Codex can discover the skill from the metadata alone -- the skill body is short enough to load cheaply -- the agent only needs references when the task gets specific - -## Phase 4: Vibe-Coder Experience - -### Objective - -Make Screenstage feel natural in fast, iterative coding loops where the user says things like: - -- "make me a clean demo of the checkout flow" -- "record a quick video of the dashboard" -- "show the new onboarding in a launch-style clip" - -### Changes - -#### 1. Add intent-oriented presets - -Human and agent users both benefit from named presets that imply outcome instead of mechanics. - -Possible presets: - -- `launch-demo` -- `feature-walkthrough` -- `social-vertical` -- `bug-repro` -- `docs-snippet` - -These can resolve to output, camera, timing, and composition defaults. - -#### 2. Add a guided "capture recipe" mode - -Potential future command: - -```bash -screenstage recipe feature-walkthrough --url http://127.0.0.1:3000 -``` - -This would produce: - -- a starter config -- a demo script template -- a recommended output preset - -This is valuable because vibe coders want "the shortest path to a decent result." - -#### 3. Add better post-run summaries - -A successful run should tell both humans and agents: - -- where the video is -- where the manifest is -- whether there were warnings -- what the next best action is - -Examples: - -- review `final.mp4` -- edit `generated-demo.mjs` -- rerun with a different preset - -### Deliverables - -- at least one intent-first preset -- an easier bootstrap path than hand-authoring config -- more actionable summaries after command completion - -### Acceptance Criteria - -- a new user can get a decent first capture with minimal domain knowledge -- an agent can map plain-English intent to Screenstage configuration with fewer assumptions - -## Phase 5: Programmatic API - -### Objective - -Expose a small API for developers and advanced agent systems that do not want to shell out to the CLI. - -### Recommendation - -Add a deliberately narrow API instead of exporting internals: - -```ts -runCapture(options): Promise -recordCapture(options): Promise -loadConfig(path): Promise -``` - -Keep the API centered on the same contract as the CLI: - -- resolved inputs -- status -- manifest path -- artifact paths -- warnings - -### Why This Matters - -This gives you: - -- better embedding into custom dev tools -- less shell parsing -- easier future MCP implementation -- a more stable public surface than exposing internal modules directly - -### Deliverables - -- minimal public API -- typed result objects -- documentation aligned with CLI behavior - -### Acceptance Criteria - -- a Node script can drive Screenstage without invoking the CLI -- API results map cleanly to the manifest contract - -## Phase 6: MCP Server - -### Objective - -Expose Screenstage to agent ecosystems that prefer tool calling over shell execution. - -### Important Constraint - -Only do this after Phases 1 through 3 are in good shape. - -An MCP server should be a thin wrapper around stable underlying capabilities, not a place where business logic gets reimplemented. - -### Likely MCP Tools - -- `screenstage_init_project` -- `screenstage_run_capture` -- `screenstage_record_capture` -- `screenstage_get_manifest` -- `screenstage_list_outputs` - -Possible future tools: - -- `screenstage_generate_config` -- `screenstage_suggest_preset` - -### Input Design - -The MCP tools should accept plain, compact parameters: - -- target URL or config path -- output intent -- viewport -- preset -- whether manual or scripted capture is desired - -The implementation can then generate or resolve full config internally. - -### Output Design - -Return: - -- manifest data -- key artifact paths -- warnings -- failure code and message - -Avoid returning raw log streams unless the caller asks for them. - -### Deliverables - -- separate MCP package or folder -- thin wrapper over the stable core -- docs for tool contracts - -### Acceptance Criteria - -- an MCP-compatible agent can execute a capture without understanding repo internals -- the MCP server does not duplicate core logic already owned by the CLI or API layer - -## Recommended Execution Order - -### Track 1: Short-Term - -1. Stabilize manifest and output contract. -2. Add `--json`, exit codes, and non-interactive flags. -3. Add one agent-oriented example and README docs. -4. Create the Codex skill. - -### Track 2: Medium-Term - -1. Add intent-oriented presets. -2. Add a simpler recipe/bootstrap workflow. -3. Add a minimal public JavaScript API. - -### Track 3: Long-Term - -1. Build an MCP server on top of the stable core. -2. Add richer prompt-to-config helpers if needed. - -## What To Avoid - -- do not make the skill huge; keep it procedural and rely on references -- do not make MCP the first integration layer -- do not require agents to scrape human log output -- do not let artifact discovery depend on naming conventions alone -- do not expose a broad unstable API surface too early -- do not optimize for "magic prompts" before the CLI contract is dependable - -## Success Metrics - -Screenstage is ready for agents when these statements are true: - -- an agent can discover how to use it from docs or skill metadata in under a minute -- an agent can run it without getting stuck in interactive prompts -- an agent can detect and classify failures automatically +- an agent can discover the right command from docs or skill metadata quickly +- an agent can run a capture without getting stuck in interactive prompts +- an agent can classify common failures from exit code plus JSON output - an agent can find the final video and supporting artifacts from the manifest alone -- a developer can embed the core flow without shell-specific hacks - -## Concrete Next Actions - -Recommended next implementation steps for this repo: - -1. Document the manifest schema and treat it as a public contract. -2. Add `--json` support to `run` first, then `record`, then `init`. -3. Add explicit non-interactive flags and stable exit codes. -4. Update the README with an "AI and agent workflows" section. -5. Create `skills/screenstage/` with a short `SKILL.md` and two reference files. -6. Reassess whether a JS API is needed before building MCP. - -## Final Recommendation - -Yes, you should make a skill. But the real product move is making Screenstage an agent-grade capture engine with a stable machine contract. - -The best path is: - -- skill now for immediate usability -- CLI hardening next for reliability -- API after that for embeddability -- MCP last for ecosystem reach - -That sequence keeps the scope disciplined and gives you useful value at every step. +- a developer can embed the core flow without shell-specific adapters diff --git a/docs/authoring.md b/docs/authoring.md index 6e6caa4..d70cfa0 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -278,7 +278,7 @@ Implemented now: - MP4 and ProRes rendering - starter scaffold -Not implemented yet: +Out of scope today: - Remotion pipeline - transparent-alpha export diff --git a/docs/cli-contract.md b/docs/cli-contract.md index eb4aa23..4407447 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -264,9 +264,12 @@ For agent integrations: The following flags now override config file behavior for `run` and `record`: - `--output-dir ` +- `--browser-channel ` - `--headless` - `--visible` +Use `--browser-channel chrome` or `--browser-channel msedge` to run with an installed Chromium-family browser instead of Playwright's bundled Chromium. + For `init`, agents can avoid prompts with: - `--yes` diff --git a/docs/config-reference.md b/docs/config-reference.md index 71adc7c..c4c2eac 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -34,6 +34,7 @@ export default { capture: { mode: "video", }, + channel: "chrome", cursor: { mode: "motion", }, @@ -69,6 +70,7 @@ export default { - `browser.studio.enabled` wraps local targets in a same-origin studio shell so the recorder controls sit outside the captured app stage. - `browser.studio.controlsWidth` and `browser.studio.padding` tune that wrapper layout. - `browser.capture.mode` controls manual recording fidelity: `video`, `balanced`, or `rgb-frames`. +- `browser.channel` can point Playwright at an installed Chromium-family browser such as `"chrome"` or `"msedge"` instead of the Playwright-managed Chromium download. - `browser.cursor.mode` controls which cursor ends up in the recording: `motion` or `app`. - `browser.cursor.hideSelectors` lets you hide custom DOM cursor layers when you want Screenstage's cursor but the app also renders its own follower elements. - `setup` lets you put the app into the right pre-record state before capture starts. diff --git a/package-lock.json b/package-lock.json index 911d7c5..ee913c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,26 @@ { - "name": "motion-creator", - "version": "0.2.2", + "name": "screenstage", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "motion-creator", - "version": "0.2.2", + "name": "screenstage", + "version": "0.2.3", "license": "MIT", "dependencies": { "playwright": "^1.54.2" }, "bin": { - "motion-creator": "dist/cli.js" + "screenstage": "dist/cli.js" }, "devDependencies": { "@types/node": "^24.3.0", "tsx": "^4.20.5", "typescript": "^5.9.2" + }, + "engines": { + "node": ">=22" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 759406c..72debaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "screenstage", - "version": "0.2.2", + "version": "0.2.3", "description": "TypeScript CLI for cursor-following product demo videos with Playwright and FFmpeg.", "repository": { "type": "git", diff --git a/readme.md b/readme.md index ec5fa82..3b76929 100644 --- a/readme.md +++ b/readme.md @@ -22,13 +22,23 @@ The output pipeline is the same either way. The difference is whether cursor and ## Quick Start +Install the system tools Screenstage shells out to while rendering: + +```bash +brew install ffmpeg librsvg +``` + +`ffmpeg` renders the final videos. `librsvg` provides `rsvg-convert`, which rasterizes the composition shell; Inkscape also works if you prefer that. + Install dependencies: ```bash npm install -npx playwright install chromium +npx playwright install ffmpeg ``` +If you do not already have Chrome or Edge installed, also run `npx playwright install chromium`. Otherwise use an installed Chromium-family browser with `--browser-channel chrome` or `browser.channel: "chrome"` in your config. + Build the CLI: ```bash @@ -38,8 +48,8 @@ npm run build Try the bundled example: ```bash -node dist/cli.js run ./examples/quickstart/screenstage.config.mjs -node dist/cli.js record ./examples/quickstart/screenstage.config.mjs +node dist/cli.js run ./examples/quickstart/screenstage.config.mjs --browser-channel chrome +node dist/cli.js record ./examples/quickstart/screenstage.config.mjs --browser-channel chrome ``` ## Two Workflows @@ -81,6 +91,7 @@ Useful overrides: ```bash screenstage run ./demo-project/screenstage.config.mjs --json --output-dir ./tmp/screenstage screenstage record ./demo-project/screenstage.config.mjs --json --visible +screenstage run ./demo-project/screenstage.config.mjs --browser-channel chrome ``` The CLI contract is documented in [docs/cli-contract.md](./docs/cli-contract.md). diff --git a/src/cli.ts b/src/cli.ts index 1c1ea25..dcb5373 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,8 +9,8 @@ function printHelp(): void { Usage: screenstage init [directory] [--yes] - screenstage record [--json] [--output-dir ] [--headless|--visible] - screenstage run [--json] [--output-dir ] [--headless|--visible] + screenstage record [--json] [--output-dir ] [--browser-channel ] [--headless|--visible] + screenstage run [--json] [--output-dir ] [--browser-channel ] [--headless|--visible] Commands: init Run the guided config wizard or scaffold a starter project in non-interactive shells. @@ -20,6 +20,8 @@ Commands: Options: --json Emit newline-delimited JSON events for machine consumption. --output-dir Override the configured output directory for this command. + --browser-channel + Use an installed Chromium-family browser channel, such as chrome or msedge. --headless Force headless browser mode for this command. --visible Force visible browser mode for this command. --yes Skip prompts for init and scaffold non-interactively. @@ -27,6 +29,7 @@ Options: } type ParsedCliArgs = { + browserChannel?: string; command?: string; headless?: boolean; json: boolean; @@ -40,6 +43,7 @@ function parseArgs(argv: string[]): ParsedCliArgs { const command = args.shift(); let value: string | undefined; let outputDir: string | undefined; + let browserChannel: string | undefined; let json = false; let headless: boolean | undefined; let nonInteractive = false; @@ -82,6 +86,21 @@ function parseArgs(argv: string[]): ParsedCliArgs { continue; } + if (arg === "--browser-channel") { + const next = args[index + 1]; + + if (!next || next.startsWith("-")) { + throw new ScreenstageError( + "INVALID_ARGUMENTS", + "Usage: --browser-channel ", + ); + } + + browserChannel = next; + index += 1; + continue; + } + if (arg.startsWith("-")) { throw new ScreenstageError( "INVALID_ARGUMENTS", @@ -101,6 +120,7 @@ function parseArgs(argv: string[]): ParsedCliArgs { } return { + browserChannel, command, headless, json, @@ -115,7 +135,7 @@ async function main(): Promise { const reporter = parsedArgs.json ? createJsonReporter() : createHumanReporter(); try { - const { command, headless, json, nonInteractive, outputDir, value } = parsedArgs; + const { browserChannel, command, headless, json, nonInteractive, outputDir, value } = parsedArgs; if (!command || command === "--help" || command === "-h") { printHelp(); @@ -123,7 +143,7 @@ async function main(): Promise { } if (command === "init") { - if (json || outputDir || headless !== undefined) { + if (browserChannel || json || outputDir || headless !== undefined) { throw new ScreenstageError( "INVALID_ARGUMENTS", "`init` only supports [directory] and `--yes`.", @@ -145,6 +165,7 @@ async function main(): Promise { await runMotion(value, { configOverrides: { + browserChannel, headless, outputDir, }, @@ -163,6 +184,7 @@ async function main(): Promise { await recordMotion(value, { configOverrides: { + browserChannel, headless, outputDir, }, diff --git a/src/config.ts b/src/config.ts index 79dff94..1b549c7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -250,6 +250,7 @@ function resolveBrowserDomain(url: string): string | undefined { } export type LoadConfigOverrides = { + browserChannel?: string; headless?: boolean; outputDir?: string; }; @@ -315,6 +316,11 @@ export async function loadConfig( const defaultBrowserDomain = resolveBrowserDomain(config.url); const browserCaptureMode = config.browser?.capture?.mode ?? DEFAULTS.browser.capture.mode; + const browserChannel = + overrides.browserChannel?.trim() || + (typeof config.browser?.channel === "string" + ? config.browser.channel.trim() + : undefined); const browserCursorMode = config.browser?.cursor?.mode ?? DEFAULTS.browser.cursor.mode; const browserCursorHideSelectors = normalizeStringList( @@ -337,6 +343,10 @@ export async function loadConfig( VALID_BROWSER_CURSOR_MODES.has(browserCursorMode), "Config browser.cursor.mode must be 'motion' or 'app'.", ); + assertCondition( + !config.browser?.channel || typeof config.browser.channel === "string", + "Config browser.channel must be a string like 'chrome' or 'msedge'.", + ); const presetOutput = OUTPUT_PRESETS[outputPreset]; const formats = config.output?.formats ?? presetOutput.formats; const browserCaptureFps = @@ -404,7 +414,7 @@ export async function loadConfig( hideSelectors: browserCursorHideSelectors, mode: browserCursorMode, }, - channel: config.browser?.channel, + channel: browserChannel, headless: overrides.headless ?? config.browser?.headless ?? DEFAULTS.browser.headless, slowMo: config.browser?.slowMo ?? DEFAULTS.browser.slowMo, diff --git a/src/record.ts b/src/record.ts index c848077..8c0dd06 100644 --- a/src/record.ts +++ b/src/record.ts @@ -639,6 +639,28 @@ export async function recordMotion( const navigationUrl = studioSession?.wrapperUrl ?? captureUrl; let manifest: SessionManifest | undefined; let manifestPath: string | undefined; + let browser: BrowserInstance | undefined; + let context: BrowserContext | undefined; + let page: Page | undefined; + let video: ReturnType | undefined; + let recording: ManualRecording | undefined; + let automationError: unknown; + let controllerWindow: ManualControllerWindow | undefined; + let frameCapture: ManualFrameCapture | undefined; + let cleanupStarted = false; + const cleanup = async () => { + if (cleanupStarted) { + return; + } + cleanupStarted = true; + + await controllerWindow?.close().catch(() => undefined); + await page?.close().catch(() => undefined); + await context?.close().catch(() => undefined); + await browser?.close().catch(() => undefined); + await studioSession?.stop().catch(() => undefined); + await managedService?.stop().catch(() => undefined); + }; reporter?.emit({ command: "record", @@ -648,126 +670,128 @@ export async function recordMotion( sessionDir, }); - managedService = await startManagedService(config); + try { + managedService = await startManagedService(config); + + if (managedService) { + reporter?.emit({ + command: "record", + configPath: config.configPath, + event: "service_started", + targetUrl: captureUrl, + }); + } - if (managedService) { - reporter?.emit({ - command: "record", - configPath: config.configPath, - event: "service_started", - targetUrl: captureUrl, - }); - } + await fs.mkdir(recordingsDir, { recursive: true }); - await fs.mkdir(recordingsDir, { recursive: true }); + try { + browser = await chromium.launch({ + channel: config.browser.channel, + headless: config.browser.headless, + slowMo: config.browser.slowMo, + }); + } catch (error) { + throw new ScreenstageError( + "BROWSER_FAILURE", + error instanceof Error ? error.message : String(error), + { + browserChannel: config.browser.channel, + headless: config.browser.headless, + }, + ); + } - let browser: BrowserInstance; - try { - browser = await chromium.launch({ - channel: config.browser.channel, + reporter?.emit({ + browserChannel: config.browser.channel ?? "chromium", + command: "record", + event: "browser_started", headless: config.browser.headless, - slowMo: config.browser.slowMo, }); - } catch (error) { - throw new ScreenstageError( - "BROWSER_FAILURE", - error instanceof Error ? error.message : String(error), - { - browserChannel: config.browser.channel, - headless: config.browser.headless, - }, - ); - } - - reporter?.emit({ - browserChannel: config.browser.channel ?? "chromium", - command: "record", - event: "browser_started", - headless: config.browser.headless, - }); - let storageState: StorageState | undefined; + let storageState: StorageState | undefined; - if (config.setup) { - const setupContext = await browser.newContext({ - viewport: studioSession?.viewport ?? config.viewport, - }); - await applyContextSetup(config, setupContext, navigationUrl); - const setupPage = await setupContext.newPage(); - await applyPageEmulation(config, setupPage); - setupPage.setDefaultNavigationTimeout(config.timing.navigationTimeoutMs); - await setupPage.goto(navigationUrl, { waitUntil: "load" }); + if (config.setup) { + const setupContext = await browser.newContext({ + viewport: studioSession?.viewport ?? config.viewport, + }); + let setupPage: Page | undefined; - if (studioSession) { - await setupPage.waitForSelector("#__motion_stage"); - const setupFrame = setupPage.frame({ name: "motion-stage" }); + try { + await applyContextSetup(config, setupContext, navigationUrl); + setupPage = await setupContext.newPage(); + await applyPageEmulation(config, setupPage); + setupPage.setDefaultNavigationTimeout(config.timing.navigationTimeoutMs); + await setupPage.goto(navigationUrl, { waitUntil: "load" }); + + if (studioSession) { + await setupPage.waitForSelector("#__motion_stage"); + const setupFrame = setupPage.frame({ name: "motion-stage" }); + + if (!setupFrame) { + throw new Error("Studio mode could not find the embedded app frame during setup."); + } + + await setupFrame.waitForLoadState("load"); + await applyPageSetup( + config, + setupContext, + setupPage, + setupFrame, + sessionDir, + captureUrl, + ); + } else { + await applyPageSetup( + config, + setupContext, + setupPage, + setupPage, + sessionDir, + captureUrl, + ); + } - if (!setupFrame) { - throw new Error("Studio mode could not find the embedded app frame during setup."); + storageState = await setupContext.storageState({ + path: path.join(sessionDir, "setup-state.json"), + }); + } finally { + await setupPage?.close().catch(() => undefined); + await setupContext.close().catch(() => undefined); } - - await setupFrame.waitForLoadState("load"); - await applyPageSetup( - config, - setupContext, - setupPage, - setupFrame, - sessionDir, - captureUrl, - ); - } else { - await applyPageSetup( - config, - setupContext, - setupPage, - setupPage, - sessionDir, - captureUrl, - ); } - storageState = await setupContext.storageState({ - path: path.join(sessionDir, "setup-state.json"), + context = await browser.newContext({ + ...(storageState ? { storageState } : {}), + ...(useFrameCapture + ? {} + : { + recordVideo: { + dir: recordingsDir, + size: studioSession?.viewport ?? config.viewport, + }, + }), + viewport: studioSession?.viewport ?? config.viewport, }); - await setupPage.close(); - await setupContext.close(); - } - - const context = await browser.newContext({ - ...(storageState ? { storageState } : {}), - ...(useFrameCapture - ? {} - : { - recordVideo: { - dir: recordingsDir, - size: studioSession?.viewport ?? config.viewport, - }, - }), - viewport: studioSession?.viewport ?? config.viewport, - }); - await applyContextSetup(config, context, navigationUrl); + await applyContextSetup(config, context, navigationUrl); - const page = await context.newPage(); - await applyPageEmulation(config, page); - page.setDefaultNavigationTimeout(config.timing.navigationTimeoutMs); + page = await context.newPage(); + await applyPageEmulation(config, page); + page.setDefaultNavigationTimeout(config.timing.navigationTimeoutMs); + const activePage = page; - const initialPoint = { - x: config.viewport.width / 2, - y: config.viewport.height / 2, - }; - const video = useFrameCapture ? undefined : page.video(); - let recording: ManualRecording | undefined; - let automationError: unknown; - let controllerWindow: ManualControllerWindow | undefined; - let frameCapture: ManualFrameCapture | undefined; + const initialPoint = { + x: config.viewport.width / 2, + y: config.viewport.height / 2, + }; + video = useFrameCapture ? undefined : page.video(); - try { - reporter?.emit({ - captureUrl, - command: "record", - event: "capture_started", - viewport: config.viewport, - }); + try { + reporter?.emit({ + captureUrl, + command: "record", + event: "capture_started", + viewport: config.viewport, + }); await page.goto(navigationUrl, { waitUntil: "load" }); @@ -800,7 +824,7 @@ export async function recordMotion( outputDir: framesDir, target: { screenshot: (options) => - page.screenshot({ + activePage.screenshot({ ...options, clip: { height: studioSession.captureRegion.height, @@ -816,7 +840,7 @@ export async function recordMotion( } controls = await installManualRecorderInFrame(page, frame, { onControlsFocusRequest: async () => { - await page.evaluate(() => { + await activePage.evaluate(() => { ( window as Window & { __motionStudioFocusControls?: () => void; @@ -825,7 +849,7 @@ export async function recordMotion( }); }, onMarkerStatusChange: async (label) => { - await page.evaluate((value) => { + await activePage.evaluate((value) => { ( window as Window & { __motionStudioSetLastCue?: (label: string) => void; @@ -852,7 +876,7 @@ export async function recordMotion( outputDir: framesDir, target: { screenshot: (options) => - page.screenshot({ + activePage.screenshot({ ...options, fullPage: false, }), @@ -898,13 +922,11 @@ export async function recordMotion( } await page.waitForTimeout(Math.min(config.timing.settleMs, 400)); + } finally { + await cleanup(); + } } finally { - await controllerWindow?.close().catch(() => undefined); - await page.close(); - await context.close(); - await browser.close(); - await studioSession?.stop().catch(() => undefined); - await managedService?.stop(); + await cleanup(); } const recordedVideoPath = await video?.path(); diff --git a/src/run.ts b/src/run.ts index 27c5d7b..552e287 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { BrowserContext } from "playwright"; +import type { BrowserContext, Page } from "playwright"; import { chromium } from "playwright"; import { DemoCameraController } from "./camera-controller.js"; @@ -93,144 +93,190 @@ export async function runMotion( await fs.mkdir(recordingsDir, { recursive: true }); const compositionLayout = await prepareComposition(sessionDir, config); - let browser: BrowserInstance; + let browser: BrowserInstance | undefined; + let context: BrowserContext | undefined; + let page: Page | undefined; + let video: ReturnType | undefined; + let camera: DemoCameraController | undefined; + let cursor: DemoCursorController | undefined; + let sceneMarkers: + | Awaited> + | undefined; + let cleanupStarted = false; + const cleanup = async () => { + if (cleanupStarted) { + return; + } + cleanupStarted = true; + + await page?.close().catch(() => undefined); + await context?.close().catch(() => undefined); + await browser?.close().catch(() => undefined); + await managedService?.stop().catch(() => undefined); + }; + try { - browser = await chromium.launch({ - channel: config.browser.channel, + try { + browser = await chromium.launch({ + channel: config.browser.channel, + headless: config.browser.headless, + slowMo: config.browser.slowMo, + }); + } catch (error) { + throw new ScreenstageError( + "BROWSER_FAILURE", + error instanceof Error ? error.message : String(error), + { + browserChannel: config.browser.channel, + headless: config.browser.headless, + }, + ); + } + + reporter?.emit({ + browserChannel: config.browser.channel ?? "chromium", + command: "run", + event: "browser_started", headless: config.browser.headless, - slowMo: config.browser.slowMo, }); - } catch (error) { - throw new ScreenstageError( - "BROWSER_FAILURE", - error instanceof Error ? error.message : String(error), - { - browserChannel: config.browser.channel, - headless: config.browser.headless, - }, - ); - } - reporter?.emit({ - browserChannel: config.browser.channel ?? "chromium", - command: "run", - event: "browser_started", - headless: config.browser.headless, - }); + let storageState: StorageState | undefined; - let storageState: StorageState | undefined; + if (config.setup) { + const setupContext = await browser.newContext({ + viewport: config.viewport, + }); + let setupPage: Page | undefined; - if (config.setup) { - const setupContext = await browser.newContext({ + try { + await applyContextSetup(config, setupContext, captureUrl); + setupPage = await setupContext.newPage(); + await applyPageEmulation(config, setupPage); + setupPage.setDefaultNavigationTimeout(config.timing.navigationTimeoutMs); + await setupPage.goto(captureUrl, { waitUntil: "load" }); + await applyPageSetup( + config, + setupContext, + setupPage, + setupPage, + sessionDir, + captureUrl, + ); + storageState = await setupContext.storageState({ + path: path.join(sessionDir, "setup-state.json"), + }); + } finally { + await setupPage?.close().catch(() => undefined); + await setupContext.close().catch(() => undefined); + } + } + + context = await browser.newContext({ + ...(storageState ? { storageState } : {}), + recordVideo: { + dir: recordingsDir, + size: config.viewport, + }, viewport: config.viewport, }); - await applyContextSetup(config, setupContext, captureUrl); - const setupPage = await setupContext.newPage(); - await applyPageEmulation(config, setupPage); - setupPage.setDefaultNavigationTimeout(config.timing.navigationTimeoutMs); - await setupPage.goto(captureUrl, { waitUntil: "load" }); - await applyPageSetup( - config, - setupContext, - setupPage, - setupPage, - sessionDir, - captureUrl, - ); - storageState = await setupContext.storageState({ - path: path.join(sessionDir, "setup-state.json"), - }); - await setupPage.close(); - await setupContext.close(); - } + await applyContextSetup(config, context, captureUrl); - const context = await browser.newContext({ - ...(storageState ? { storageState } : {}), - recordVideo: { - dir: recordingsDir, - size: config.viewport, - }, - viewport: config.viewport, - }); - await applyContextSetup(config, context, captureUrl); + page = await context.newPage(); + await applyPageEmulation(config, page); + page.setDefaultNavigationTimeout(config.timing.navigationTimeoutMs); - const page = await context.newPage(); - await applyPageEmulation(config, page); - page.setDefaultNavigationTimeout(config.timing.navigationTimeoutMs); - - const initialPoint = { - x: config.viewport.width / 2, - y: config.viewport.height / 2, - }; + const initialPoint = { + x: config.viewport.width / 2, + y: config.viewport.height / 2, + }; - let camera: DemoCameraController; - const cursor = new DemoCursorController({ - getCameraState: () => camera.current, - initialPoint, - page, - sampleCamera: async (point, zoom) => { - camera.current = { - x: point.x, - y: point.y, - zoom, - }; - await camera.sample("follow"); - }, - }); - camera = new DemoCameraController({ - getCursorPoint: () => cursor.current, - initialState: { - ...initialPoint, - zoom: config.camera.zoom, - }, - page, - viewportCenter: initialPoint, - }); + cursor = new DemoCursorController({ + getCameraState: () => { + if (!camera) { + throw new Error("Camera controller was not initialized."); + } - const video = page.video(); - let sceneMarkers: - | Awaited> - | undefined; + return camera.current; + }, + initialPoint, + page, + sampleCamera: async (point, zoom) => { + if (!camera) { + throw new Error("Camera controller was not initialized."); + } + + camera.current = { + x: point.x, + y: point.y, + zoom, + }; + await camera.sample("follow"); + }, + }); + camera = new DemoCameraController({ + getCursorPoint: () => { + if (!cursor) { + throw new Error("Cursor controller was not initialized."); + } - try { - reporter?.emit({ - captureUrl, - command: "run", - event: "capture_started", - viewport: config.viewport, + return cursor.current; + }, + initialState: { + ...initialPoint, + zoom: config.camera.zoom, + }, + page, + viewportCenter: initialPoint, }); - if (config.browser.cursor.mode === "motion") { - await installCursorOverlay(page, { - hideSelectors: config.browser.cursor.hideSelectors, + video = page.video(); + + try { + reporter?.emit({ + captureUrl, + command: "run", + event: "capture_started", + viewport: config.viewport, }); - } - await page.goto(captureUrl, { waitUntil: "load" }); - await applyPageSetup(config, context, page, page, sessionDir, captureUrl, { - includeModule: false, - }); - await moveCursorOverlay(page, initialPoint.x, initialPoint.y); - const demoContext = { - camera, - config, - cursor, - page, - sessionDir, - }; + if (config.browser.cursor.mode === "motion") { + await installCursorOverlay(page, { + hideSelectors: config.browser.cursor.hideSelectors, + }); + } + await page.goto(captureUrl, { waitUntil: "load" }); + await applyPageSetup(config, context, page, page, sessionDir, captureUrl, { + includeModule: false, + }); + await moveCursorOverlay(page, initialPoint.x, initialPoint.y); - if (Array.isArray(demoModule.default)) { - sceneMarkers = await runScenes(demoModule.default, demoContext); - } else { - await demoModule.default(demoContext); - } + const demoContext = { + camera, + config, + cursor, + page, + sessionDir, + }; - await cursor.wait(config.timing.settleMs); + if (Array.isArray(demoModule.default)) { + sceneMarkers = await runScenes(demoModule.default, demoContext); + } else { + await demoModule.default(demoContext); + } + + await cursor.wait(config.timing.settleMs); + } finally { + await cleanup(); + } } finally { - await page.close(); - await context.close(); - await browser.close(); - await managedService?.stop(); + await cleanup(); + } + + if (!camera || !cursor) { + throw new ScreenstageError( + "CAPTURE_FAILURE", + "Capture did not initialize.", + ); } const recordedVideoPath = await video?.path();