diff --git a/docs/agent-tooling-plan.md b/docs/agent-tooling-plan.md new file mode 100644 index 0000000..5e012a9 --- /dev/null +++ b/docs/agent-tooling-plan.md @@ -0,0 +1,533 @@ +# Screenstage Agent Tooling Plan + +## Goal + +Turn Screenstage from a strong human-operated CLI into a reliable tool that AI agents and vibe-coding workflows can use without guesswork. + +The target outcome is not just "an AI can run the CLI." The target outcome is: + +- 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 + +## Product Positioning + +Screenstage should be framed as: + +- 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 + +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. + +## Core Recommendation + +Build this in three layers, in order: + +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. + +The likely broader surface is either: + +- 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 + +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. + +## Why A Skill Is Worth Doing + +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 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. diff --git a/docs/authoring.md b/docs/authoring.md new file mode 100644 index 0000000..55fae4c --- /dev/null +++ b/docs/authoring.md @@ -0,0 +1,278 @@ +# Authoring Guide + +## Authoring Model + +Your demo module can export either: + +- a default async function for full manual control +- a default scene array for declarative shot sequencing + +If you want repeatable release-style captures, the scene array is the recommended default. + +If you want to avoid hand-building scene arrays for every launch asset, you can also generate them from the built-in templates. + +The repo ships with one public example under [../examples/quickstart/README.md](../examples/quickstart/README.md). + +## Demo Runtime API + +Async demo functions receive: + +- `page`: the Playwright page +- `cursor`: visible cursor controller +- `camera`: framing controller for post-processing +- `config`: resolved runtime config +- `sessionDir`: output directory for the active run + +### Manual Example + +```js +export default async function demo({ camera, cursor }) { + await cursor.moveToSelector("[data-demo='card-1']", { + durationMs: 950, + camera: { + follow: true, + zoomFrom: 1, + zoomTo: 1.7, + }, + }); + await camera.wait(250); + await cursor.moveToSelector("[data-demo='cta']", { + durationMs: 800, + camera: { + follow: true, + zoomFrom: 1.7, + zoomTo: 1.9, + }, + }); + await cursor.click(); + await camera.zoomOut({ durationMs: 700, followCursor: true }); +} +``` + +You can also stage camera timing inside a single move: + +```js +await cursor.moveToSelector("[data-demo='search']", { + durationMs: 1600, + camera: { + follow: true, + timingPreset: "late-arrival", + zoomFrom: 1, + zoomTo: 1.8, + }, +}); +``` + +Built-in move timing presets: + +- `"continuous"` +- `"late-arrival"` +- `"depart-reveal"` +- `"settle"` + +## Scene API + +Scene programs are exported as a plain array: + +```js +export default [ + { + type: "wide", + durationMs: 400, + label: "Start on a broad establishing shot", + }, + { + type: "focus-selector", + selector: "[data-demo='email']", + durationMs: 850, + zoom: 2, + }, + { + type: "type-selector", + selector: "[data-demo='email']", + text: "hello@getrestocky.com", + durationMs: 900, + delayMs: 75, + }, + { + type: "follow-cursor", + durationMs: 300, + }, + { + type: "move-selector", + selector: "[data-demo='cta']", + durationMs: 850, + cameraFollow: true, + timingPreset: "late-arrival", + zoomFrom: 1, + zoomTo: 1.9, + }, + { + type: "click", + }, + { + type: "zoom-out", + durationMs: 700, + followCursor: true, + }, + { + type: "wait", + durationMs: 800, + target: "camera", + }, +]; +``` + +Supported scene types: + +- `wide` +- `follow-cursor` +- `focus-selector` +- `focus-point` +- `move-selector` +- `move-point` +- `zoom-to` +- `zoom-out` +- `click` +- `click-selector` +- `type` +- `type-selector` +- `wait` + +`wait.target` accepts `"cursor"`, `"camera"`, or `"both"`. + +## Templates + +Template helpers generate scene arrays for common flows: + +- `createFeatureTour()` +- `createFormFillCapture()` +- `createHeroWalkthrough()` + +### Hero Walkthrough Example + +```js +import { createHeroWalkthrough } from "screenstage"; + +export default createHeroWalkthrough({ + fieldSelector: "[data-demo='email']", + fieldText: "hello@getrestocky.com", + ctaSelector: "[data-demo='cta']", + metricSelector: "[data-demo='card-2']", +}); +``` + +### Feature Tour Example + +```js +import { createFeatureTour } from "screenstage"; + +export default createFeatureTour({ + introPauseMs: 500, + steps: [ + { + selector: "[data-demo='email']", + action: "type", + text: "hello@getrestocky.com", + zoom: 2, + }, + { + selector: "[data-demo='cta']", + action: "click", + zoom: 1.8, + }, + { + selector: "[data-demo='card-2']", + action: "move", + cameraFollow: true, + zoom: 1.8, + pauseMs: 900, + pauseTarget: "camera", + }, + ], +}); +``` + +## Cursor Helpers + +- `cursor.move({ x, y }, options)` +- `cursor.moveToSelector(selector, options)` +- `cursor.click(options)` +- `cursor.clickSelector(selector, options)` +- `cursor.type(text, options)` +- `cursor.typeSelector(selector, text, options)` +- `cursor.wait(durationMs)` +- `cursor.sample(kind)` + +You can also import move timing helpers directly: + +```js +import { createCameraMoveTiming } from "screenstage"; + +await cursor.moveToSelector("[data-demo='search']", { + durationMs: 1600, + camera: { + follow: true, + zoomFrom: 1, + zoomTo: 1.8, + ...createCameraMoveTiming("late-arrival", { + followEnd: 0.96, + }), + }, +}); +``` + +## Camera Helpers + +- `camera.focus({ x, y }, options)` +- `camera.focusSelector(selector, options)` +- `camera.followCursor(options)` +- `camera.wide(options)` +- `camera.wait(durationMs)` +- `camera.sample(kind)` + +If you want a traditional non-followed browser recording with the composition shell still applied, set: + +```js +camera: { + mode: "static", + zoom: 1, +} +``` + +## Recommended Workflow + +For strong release-style captures: + +- use a source viewport around `1440x900` and render at `1920x1080` +- use `output.preset` for repeatable delivery targets instead of hand-tuning every config +- pause deliberately with `cursor.wait()` or `camera.wait()` so the camera has time to settle +- use `camera.focusSelector()` before important interactions instead of letting every shot be cursor-led +- increase `camera.smoothingMs` if a cursor-led sequence still feels twitchy, or reduce it if the camera feels too lazy +- switch `camera.mode` to `"static"` when you want a composed showcase clip that behaves like a normal screen recording +- use `cursor.typeSelector()` for form entries so the footage reads like a real person using the app +- export `prores` when the clip is headed into Motion or Final Cut for finishing + +## Current Scope + +Implemented now: + +- real browser recording +- realistic cursor overlay +- hover-aware cursor variants +- manual camera keyframes +- declarative scene arrays +- local dev-server lifecycle +- browser-shell composition presets +- smoother cursor-led camera tracking +- reusable output presets +- reusable scene templates +- poster frame and contact sheet review artifacts +- MP4 and ProRes rendering +- starter scaffold + +Not implemented yet: + +- Remotion pipeline +- transparent-alpha export +- timeline editor or a more advanced scene DSL beyond the current script API diff --git a/docs/cli-contract.md b/docs/cli-contract.md new file mode 100644 index 0000000..eb4aa23 --- /dev/null +++ b/docs/cli-contract.md @@ -0,0 +1,272 @@ +# Screenstage CLI Contract + +This document defines the first machine-facing contract for Screenstage. + +## Scope + +Current support: + +- `screenstage run --json` +- `screenstage record --json` + +Current non-goals: + +- `init --json` + +`run --json` and `record --json` are the first stable agent-facing paths. + +## JSON Event Stream + +When `--json` is passed to `screenstage run` or `screenstage record`, Screenstage writes newline-delimited JSON events to stdout. + +Each line is one JSON object with an `event` field. + +Human-readable renderer diagnostics are written to stderr when present and are not part of the JSON event stream. FFmpeg now runs in error-only mode by default, so successful renders should not spam progress output. + +### Events + +`command_started` + +```json +{ + "event": "command_started", + "command": "run", + "configPath": "/abs/path/to/screenstage.config.mjs", + "outputDir": "./output", + "sessionDir": "output/demo-2026-03-09T12-00-00.000Z" +} +``` + +`service_started` + +```json +{ + "event": "service_started", + "command": "run", + "configPath": "/abs/path/to/screenstage.config.mjs", + "targetUrl": "http://127.0.0.1:3000" +} +``` + +`browser_started` + +```json +{ + "event": "browser_started", + "command": "run", + "browserChannel": "chromium", + "headless": true +} +``` + +`capture_started` + +```json +{ + "event": "capture_started", + "command": "run", + "captureUrl": "http://127.0.0.1:3000", + "viewport": { + "width": 1440, + "height": 900 + } +} +``` + +`capture_completed` + +```json +{ + "event": "capture_completed", + "command": "run", + "sourceVideoPath": "output/demo/source.webm" +} +``` + +`render_started` + +```json +{ + "event": "render_started", + "command": "run", + "plannedOutputs": [ + { + "format": "mp4", + "outputPath": "output/demo/final.mp4" + } + ] +} +``` + +`artifacts_written` + +```json +{ + "event": "artifacts_written", + "command": "run", + "manifestPath": "output/demo/manifest.json", + "sessionDir": "output/demo", + "artifacts": { + "source": { + "path": "source.webm", + "type": "source-video" + } + } +} +``` + +`render_completed` + +```json +{ + "event": "render_completed", + "command": "run", + "durationMs": 18452, + "manifestPath": "output/demo/manifest.json" +} +``` + +`command_completed` + +```json +{ + "event": "command_completed", + "command": "run", + "durationMs": 18452, + "manifestPath": "output/demo/manifest.json", + "sessionDir": "output/demo", + "status": "success" +} +``` + +If `ffmpeg` is unavailable, Screenstage still writes a manifest and finishes with: + +```json +{ + "event": "command_completed", + "command": "run", + "durationMs": 9421, + "manifestPath": "output/demo/manifest.json", + "sessionDir": "output/demo", + "status": "partial", + "warning": "ffmpeg_missing" +} +``` + +`command_failed` + +```json +{ + "event": "command_failed", + "command": "run", + "code": "TARGET_UNAVAILABLE", + "exitCode": 3, + "message": "Timed out waiting for http://127.0.0.1:3000 to respond." +} +``` + +## Exit Codes + +- `0`: success +- `2`: invalid arguments or invalid config +- `3`: target unavailable or dev server did not become ready +- `4`: browser launch failure +- `5`: capture failure +- `6`: render failure +- `7`: required dependency missing + +## Manifest Contract + +Each successful `run` writes `manifest.json` inside the session directory. +Each successful `record` also writes `manifest.json` unless the manual recording was explicitly cancelled. + +Important fields: + +- `schemaVersion`: current manifest version +- `session.dir`: absolute session directory path +- `session.name`: session folder name +- `captureUrl`: resolved URL used for capture +- `config`: compact summary of the resolved config +- `artifacts`: relative paths to generated outputs +- artifact paths are relative when the file lives inside the session directory and absolute when it lives elsewhere +- `markers`: marker summary +- `durationSeconds`: video duration when available + +### Example + +```json +{ + "schemaVersion": 1, + "mode": "run", + "createdAt": "2026-03-09T12:00:00.000Z", + "session": { + "name": "demo-2026-03-09T12-00-00.000Z", + "dir": "/abs/path/to/output/demo-2026-03-09T12-00-00.000Z" + }, + "captureUrl": "http://127.0.0.1:3000", + "config": { + "name": "demo", + "outputPreset": "release-hero", + "fps": 30, + "viewport": { + "width": 1440, + "height": 900 + } + }, + "camera": { + "mode": "follow", + "preset": "showcase-follow", + "zoom": 1.75 + }, + "composition": { + "preset": "studio-browser", + "device": "desktop" + }, + "markerCount": 2, + "markers": [ + { + "label": "Feature ready", + "source": "scene", + "timeMs": 1800, + "type": "focus" + } + ], + "artifacts": { + "source": { + "path": "source.webm", + "type": "source-video" + }, + "timeline": { + "path": "timeline.json", + "type": "timeline" + }, + "finalRenders": [ + { + "path": "final.mp4", + "type": "final-mp4" + } + ] + } +} +``` + +## Integration Guidance + +For agent integrations: + +- use `command_completed` or `command_failed` as the terminal event +- use `manifestPath` as the canonical handoff into artifact inspection +- resolve artifact paths relative to the manifest's session directory unless the artifact path is already absolute +- prefer the manifest over shell log parsing + +## CLI Overrides + +The following flags now override config file behavior for `run` and `record`: + +- `--output-dir ` +- `--headless` +- `--visible` + +For `init`, agents can avoid prompts with: + +- `--yes` diff --git a/docs/config-reference.md b/docs/config-reference.md new file mode 100644 index 0000000..4058433 --- /dev/null +++ b/docs/config-reference.md @@ -0,0 +1,276 @@ +# Config Reference + +`screenstage.config.mjs` exports a default object: + +```js +export default { + name: "starter-demo", + url: new URL("./demo-site/index.html", import.meta.url).href, + demo: "./demo/starter-demo.mjs", + viewport: { + width: 1440, + height: 900, + }, + output: { + dir: "./output", + preset: "release-hero", + }, + camera: { + preset: "showcase-follow", + zoom: 1.7, + }, + composition: { + preset: "studio-browser", + device: "desktop", + background: { + preset: "soft-studio", + }, + browser: { + domain: "app.example.com", + style: "polished", + }, + }, + browser: { + capture: { + mode: "video", + }, + cursor: { + mode: "motion", + }, + headless: true, + studio: { + enabled: true, + }, + }, + timing: { + settleMs: 900, + }, +}; +``` + +## Config Notes + +- `url` can point to any reachable web app, including local HTML files, local dev servers, or deployed apps. +- `output.preset` gives you sensible defaults for common delivery targets. You can still override `width`, `height`, `fps`, or `formats` manually when a preset is close but not exact. +- `output.formats` accepts `"mp4"` and `"prores"`. +- `camera.zoom` is the default follow-cam zoom when you are not manually keyframing the camera. +- `camera.preset` gives you a tuned baseline before any manual overrides. +- `camera.mode` can be `"follow"` or `"static"`. +- `camera.padding` keeps the target away from the crop edge. +- `camera.smoothingMs` softens raw cursor-led camera tracking. +- `camera.deadzonePx` prevents tiny cursor changes from nudging the camera. +- `camera.verticalWeight` lets the follow cam react less aggressively to small vertical cursor noise. +- `composition.preset` controls the presentation shell around the app capture. +- `composition.device` controls whether that shell is a desktop browser or a phone frame. +- `composition.background.colors` and `composition.background.angle` control the shell background. +- `composition.background.preset` gives you named backdrop looks without hand-picking gradient stops. +- `composition.browser.domain` sets the label shown in the browser address bar. +- `composition.browser.style` changes the desktop browser chrome mood. +- `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.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. + +## Presets + +### Camera Presets + +- `"showcase-follow"`: balanced default for release-style motion with calmer hover behavior +- `"tight-follow"`: more responsive for compact UI and faster travel +- `"lazy-follow"`: slower, calmer tracking for broad navigation or hover-heavy sequences +- `"static"`: fixed framing with no follow-cam movement + +### Output Presets + +- `"release-hero"`: 1920x1080, 30 fps, `mp4` + `prores` +- `"social-square"`: 1080x1080, 30 fps, `mp4` +- `"social-vertical"`: 1080x1920, 30 fps, `mp4` +- `"motion-edit"`: 2560x1440, 30 fps, `mp4` + `prores` + +### Composition Presets + +- `"none"`: raw full-frame render with no presentation shell +- `"studio-browser"`: soft light background with polished browser chrome +- `"spotlight-browser"`: darker, more cinematic presentation shell + +### Composition Devices + +- `"desktop"`: browser-window shell that scales proportionally with the output size +- `"phone"`: mobile-device shell for portrait exports and phone-sized viewports + +### Background Presets + +- `"soft-studio"`: airy neutral green-blue backdrop +- `"warm-editor"`: warmer editorial paper-and-sky mix +- `"cool-stage"`: cooler product-launch backdrop +- `"midnight-fade"`: dark cinematic stage + +### Browser Styles + +- `"polished"`: balanced default with fuller chrome and depth +- `"minimal"`: quieter shell with less glow and ornament +- `"glass"`: brighter, more luminous shell treatment + +## Examples + +### Shell Customization + +```js +composition: { + preset: "studio-browser", + device: "desktop", + background: { + preset: "warm-editor", + }, + browser: { + domain: "launch.example.com", + style: "glass", + }, +} +``` + +### Phone Shell + +```js +composition: { + preset: "studio-browser", + device: "phone", + phone: { + color: "#10141a", + }, +} +``` + +### Local App With Managed Dev Server + +```js +export default { + url: "http://127.0.0.1:3000", + demo: "./demo/starter-demo.mjs", + serve: { + command: "npm run dev", + cwd: ".", + readyText: "ready", + timeoutMs: 30000, + }, +}; +``` + +`serve.command` is started before capture, the tool waits for `url` to respond, and the process is shut down when recording finishes. + +### Camera Override + +```js +camera: { + preset: "lazy-follow", + zoom: 1.45, +} +``` + +## Setup Hooks + +`setup` is the pre-capture state layer. It is meant for getting a real app into the right browser state before recording starts, not for editing the final video. + +### Declarative Example + +```js +setup: { + route: "/docs/palette-lab", + query: { + mode: "dark", + panel: "tokens", + }, + colorScheme: "dark", + localStorage: { + "duotone:theme": "dark", + "duotone:last-tab": "tokens", + }, + sessionStorage: { + "motion:capture": "true", + }, + waitFor: { + selector: "[data-ready='true']", + timeoutMs: 10000, + }, +}, +``` + +### Setup Module + +```js +setup: { + module: "./demo/setup-app.mjs", +}, +``` + +Setup modules export a default async function and receive: + +- `config` +- `context` +- `page` +- `target` +- `sessionDir` +- `url` + +`target` is the actual app surface: + +- the page itself in normal captures +- the embedded app frame in studio mode + +Example: + +```js +export default async function setup({ target }) { + await target.click("[data-demo='open-auth-bypass']"); + await target.waitForSelector("[data-demo='dashboard']"); +} +``` + +Manual overrides still win, so you can start from a preset and then tune just one value: + +```js +camera: { + preset: "showcase-follow", + deadzonePx: 28, + smoothingMs: 250, +} +``` + +## Manual Record Mode + +`record` is the human-headed capture path: + +1. Launch the target app in a visible Chromium window. +2. Inject the polished cursor overlay and recorder controls. +3. Perform the flow manually. +4. Tag camera beats while you record. +5. Finish from the controls or press `Alt+Shift+R`. +6. Get an immediate rendered video plus an editable generated demo file. + +When `browser.studio.enabled` is on, the app is loaded inside a local wrapper page and only the iframe stage is recorded. That is the recommended setup for local dev tools because the controls stay outside the shot while still feeling integrated. + +On machines with `ffmpeg` installed, manual recordings can use one of three paths: + +- `video`: the stable default using Playwright's browser-video capture +- `balanced`: JPEG frames plus a high-quality intermediate +- `rgb-frames`: PNG frames plus a lossless RGB intermediate for maximum fidelity + +For real local apps, the current recommendation is: + +- `browser.capture.mode: "video"` +- a larger source viewport like `1728x1080` on desktop +- `output.preset: "motion-edit"` with both `mp4` and `prores` + +Built-in shot markers: + +- `Alt+Shift+1`: `Wide` +- `Alt+Shift+2`: `Punch In` +- `Alt+Shift+3`: `Hold` + +Manual record sessions save these extra artifacts: + +- `recording.json`: raw captured actions and cursor samples +- `generated-demo.mjs`: a generated runnable demo module in the session folder +- `*.recorded-.mjs`: a copy of that generated demo saved next to your configured demo file so you can edit and reuse it diff --git a/docs/for-agents.md b/docs/for-agents.md new file mode 100644 index 0000000..ff872ff --- /dev/null +++ b/docs/for-agents.md @@ -0,0 +1,103 @@ +# Screenstage For Agents + +Screenstage is a browser demo video tool with two capture paths: + +- `run`: motion is preprogrammed in a demo module +- `record`: motion comes from a human driving the browser live in the headed studio workflow + +The rendered outputs are the same class of artifacts either way. The difference is where the cursor and camera movement come from. + +## What An Agent Should Do + +Use Screenstage when the task is to produce a polished browser video artifact, not just automate the browser. + +Typical requests: + +- record a feature walkthrough +- make a launch clip from localhost +- capture a changelog demo +- turn this app flow into a browser video +- generate a bug repro video + +## Choosing `run` Versus `record` + +Choose `run` when: + +- the flow should be reproducible +- the cursor and camera movement should be authored in code +- the capture is part of an iterative development loop + +Choose `record` when: + +- the human should control the mouse live +- the studio workflow is the desired recording mode +- the final outputs should still be rendered by Screenstage, but the motion should come from the live session + +## Portable Skill + +This repo includes a portable Screenstage skill at: + +`skills/screenstage/` + +That skill is intentionally generic so it can be adapted to other skill-capable agent systems. It teaches: + +- when to use Screenstage +- how to choose `run` or `record` +- how to prefer `--json` +- how to use the manifest as the output contract + +Key files: + +- `skills/screenstage/SKILL.md` +- `skills/screenstage/references/config-patterns.md` +- `skills/screenstage/references/troubleshooting.md` + +## Machine Contract + +For agent integrations, the CLI contract matters more than the skill packaging. + +Useful commands: + +```bash +screenstage run ./screenstage.config.mjs --json +screenstage record ./screenstage.config.mjs --json +screenstage init ./demo-project --yes +``` + +Useful overrides: + +```bash +screenstage run ./screenstage.config.mjs --json --output-dir ./tmp/screenstage +screenstage record ./screenstage.config.mjs --json --visible +screenstage run ./screenstage.config.mjs --json --headless +``` + +The JSON event stream and manifest contract are documented in: + +`docs/cli-contract.md` + +## Output Handoff + +An agent should prefer returning: + +- the main video path +- the manifest path +- whether the run ended as `success`, `partial`, or `cancelled` + +Do not guess file names if `manifest.json` exists. + +Use: + +- terminal JSON events for status +- `manifestPath` as the canonical handoff +- manifest artifact paths for output discovery + +## Recommended Public Positioning + +When describing Screenstage publicly, keep the distinction simple: + +- scripted capture with `run` +- human-headed studio capture with `record` +- same output pipeline, different motion source + +That framing is cleaner than describing `record` as a collaborative mode. diff --git a/readme.md b/readme.md index 68ef0e8..ec5fa82 100644 --- a/readme.md +++ b/readme.md @@ -1,93 +1,63 @@ # Screenstage -TypeScript CLI for producing polished browser-product demos from any web app. +Screenstage is a CLI for recording polished product videos from real web apps. -- record real browser interactions against any web-based UI -- keep the cursor visible in the footage with a professional-looking overlay -- direct the camera toward important UI moments instead of relying only on raw cursor-following -- author demos as a sequence of named shots instead of one long imperative script -- present the capture inside a browser-style composition shell before it reaches Motion -- export lightweight review MP4s and edit-friendly ProRes files for Apple Motion / Final Cut Pro +Point it at a local app, static page, or deployed URL and it will capture the browser, render a cleaner presentation shell around it, and export review-ready video artifacts like `final.mp4`, `poster.png`, and `manifest.json`. -This is a Playwright + FFmpeg pipeline. It works with React apps and non-React web apps because it records the browser, not the framework. +It has two capture workflows: + +- `run`: motion is preprogrammed in a demo module +- `record`: a human drives the browser live in the headed studio workflow + +The output pipeline is the same either way. The difference is whether cursor and camera motion come from code or from a live session. ## What It Does -- Opens a real Chromium page with Playwright. -- Can optionally start a local dev server, wait for it to come up, and shut it down after capture. -- Records the live session as source video. -- Injects a cursor overlay that behaves more like a real mouse: - - arrow cursor by default - - hand cursor over interactive elements - - text caret over text inputs - - click ripple and press feedback -- Lets your demo script control: - - cursor movement - - clicking - - human-looking typing - - camera focus / wide shots / reframing - - shot-by-shot scene sequencing -- Post-processes the recording in FFmpeg into: - - a composed browser presentation shell with background and chrome presets - - `mp4` for fast review/sharing - - `prores` for Motion / Final Cut / other editing - - review artifacts like a poster frame and contact sheet - -## Requirements - -- Node.js 22+ -- `ffmpeg` on `PATH` -- Playwright Chromium runtime - -Install: +- records real browser sessions with Playwright +- renders polished browser demo videos with FFmpeg +- adds a synthetic cursor overlay for cleaner footage +- exports review artifacts like poster frames, contact sheets, markers, and manifests +- supports scripted capture and human-headed studio capture +- exposes a machine-readable CLI contract and a portable skill for agent use -```bash -npm install -npx playwright install chromium -``` +## Quick Start -For package verification before publishing: +Install dependencies: ```bash -npm run pack:check +npm install +npx playwright install chromium ``` -## Open Source Readiness - -- The project is licensed under MIT. See [LICENSE](./LICENSE). -- Basic contribution guidelines live in [CONTRIBUTING.md](./CONTRIBUTING.md). -- CI runs `npm run check` and `npm run build` on pushes and pull requests. -- Release notes and publishing steps live in [RELEASING.md](./RELEASING.md). -- The current release history lives in [CHANGELOG.md](./CHANGELOG.md). - -## Quick Start - Build the CLI: ```bash npm run build ``` -Use it locally without publishing: +Try the bundled example: ```bash -npm link -screenstage --help +node dist/cli.js run ./examples/quickstart/screenstage.config.mjs +node dist/cli.js record ./examples/quickstart/screenstage.config.mjs ``` -Scaffold a starter project: +## Two Workflows + +### Scripted Capture + +Use `run` when the flow should be repeatable and the motion should be authored in code. ```bash -node dist/cli.js init ./demo-project +screenstage run ./demo-project/screenstage.config.mjs ``` -If you run `init` in a terminal, it opens a short wizard and writes `screenstage.config.mjs` for you. In non-interactive shells it falls back to the old starter scaffold. +### Live Studio Capture -If you just want to try the tool against the bundled example first: +Use `record` when a human should control the mouse live in the browser and Screenstage should render the same class of output artifacts from that session. ```bash -node dist/cli.js run ./examples/quickstart/screenstage.config.mjs -node dist/cli.js record ./examples/quickstart/screenstage.config.mjs +screenstage record ./demo-project/screenstage.config.mjs ``` ## Sample Output @@ -96,614 +66,42 @@ node dist/cli.js record ./examples/quickstart/screenstage.config.mjs See the bundled quickstart render here: [quickstart-sample.mp4](./docs/assets/quickstart-sample.mp4) -Run the starter demo: +## Agent Use -```bash -node dist/cli.js run ./demo-project/screenstage.config.mjs -``` - -Record a manual session instead of scripting the mouse: - -```bash -node dist/cli.js record ./demo-project/screenstage.config.mjs -``` - -Each run creates a timestamped folder inside the configured output directory with artifacts like: - -- `source.webm` -- `final.mp4` -- `final-prores.mov` -- `poster.png` -- `contact-sheet.png` -- `manifest.json` -- `markers.json` -- `markers.csv` -- `markers/` stills for each exported marker when an MP4 review render exists -- `timeline.json` -- `recording.json` for manual record sessions -- `generated-demo.mjs` for manual record sessions - -## CLI +Screenstage also supports machine-facing execution: ```bash -screenstage init [directory] -screenstage record -screenstage run +screenstage run ./demo-project/screenstage.config.mjs --json +screenstage record ./demo-project/screenstage.config.mjs --json +screenstage init ./demo-project --yes ``` -Development shortcuts: +Useful overrides: ```bash -npm run dev -- init ./demo-project -npm run dev -- record ./demo-project/screenstage.config.mjs -npm run dev -- run ./demo-project/screenstage.config.mjs +screenstage run ./demo-project/screenstage.config.mjs --json --output-dir ./tmp/screenstage +screenstage record ./demo-project/screenstage.config.mjs --json --visible ``` -`init` is non-destructive. It only writes starter files that do not already exist, and in a normal terminal it now asks a few setup questions instead of making you hand-author the config. -`record` opens a visible browser, lets you perform the flow manually, then writes an editable demo module alongside the raw capture artifacts. Manual recording defaults to the stable browser-video path now, and you can opt into higher-fidelity frame capture with `browser.capture.mode` when you specifically want it. -Both `run` and `record` export `markers.json` and `markers.csv` so you can line capture beats up in your real edit. -Each session also writes a `manifest.json` that summarizes the capture, output artifacts, and marker list in one place. -For local apps and local fixtures, you can enable embedded studio mode so the controls live outside the captured app stage instead of inside the page or in a separate popup. - -## Config - -`screenstage.config.mjs` exports a default object: - -```js -export default { - name: "starter-demo", - url: new URL("./demo-site/index.html", import.meta.url).href, - demo: "./demo/starter-demo.mjs", - viewport: { - width: 1440, - height: 900, - }, - output: { - dir: "./output", - preset: "release-hero", - }, - camera: { - preset: "showcase-follow", - zoom: 1.7, - }, - composition: { - preset: "studio-browser", - device: "desktop", - background: { - preset: "soft-studio", - }, - browser: { - domain: "app.example.com", - style: "polished", - }, - }, - browser: { - capture: { - mode: "video", - }, - cursor: { - mode: "motion", - }, - headless: true, - studio: { - enabled: true, - }, - }, - timing: { - settleMs: 900, - }, -}; -``` +The CLI contract is documented in [docs/cli-contract.md](./docs/cli-contract.md). +The agent integration overview is in [docs/for-agents.md](./docs/for-agents.md). -### Config Notes - -- `url` can point to any reachable web app, including local HTML files, local dev servers, or deployed apps. -- `output.preset` gives you sensible defaults for common delivery targets. You can still override `width`, `height`, `fps`, or `formats` manually when a preset is close but not exact. -- `output.formats` accepts: - - `"mp4"` for lightweight H.264 output - - `"prores"` for high-quality `.mov` output that is better suited for Motion / Final Cut -- `camera.zoom` is the default follow-cam zoom when you are not manually keyframing the camera. -- `camera.preset` gives you a tuned baseline before any manual overrides. -- `camera.mode` can be `"follow"` for cursor-led framing or `"static"` for a fixed full-browser shot with no mouse-follow behavior. -- `camera.padding` keeps the target away from the crop edge. -- `camera.smoothingMs` softens raw cursor-led camera tracking. -- `camera.deadzonePx` prevents tiny cursor changes from nudging the camera. -- `camera.verticalWeight` lets the follow cam react less aggressively to small vertical cursor noise. -- `composition.preset` controls the presentation shell around the app capture. -- `composition.device` controls whether that shell is a desktop browser or a phone frame. -- `composition.background.colors` and `composition.background.angle` control the shell background. -- `composition.background.preset` gives you named backdrop looks without hand-picking gradient stops. -- `composition.browser.domain` sets the label shown in the browser address bar. -- `composition.browser.style` changes the desktop browser chrome mood. -- `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` is the stable default, `balanced` is a higher-fidelity middle tier, and `rgb-frames` is the highest-quality but heaviest option. -- `browser.cursor.mode` controls which cursor ends up in the recording: - `motion` uses Screenstage's synthetic cursor, while `app` leaves the app's own cursor behavior alone. -- `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. - -Current camera presets: - -- `"showcase-follow"`: balanced default for release-style motion with calmer hover behavior -- `"tight-follow"`: more responsive for compact UI and faster travel -- `"lazy-follow"`: slower, calmer tracking for broad navigation or hover-heavy sequences -- `"static"`: fixed framing with no follow-cam movement - -Current output presets: - -- `"release-hero"`: 1920x1080, 30 fps, `mp4` + `prores` -- `"social-square"`: 1080x1080, 30 fps, `mp4` -- `"social-vertical"`: 1080x1920, 30 fps, `mp4` -- `"motion-edit"`: 2560x1440, 30 fps, `mp4` + `prores` - -Current composition presets: - -- `"none"`: raw full-frame render with no presentation shell -- `"studio-browser"`: soft light background with polished browser chrome -- `"spotlight-browser"`: darker, more cinematic presentation shell - -Current composition devices: - -- `"desktop"`: browser-window shell that now scales proportionally with the output size -- `"phone"`: mobile-device shell for portrait exports and phone-sized viewports - -Shell customization example: - -```js -composition: { - preset: "studio-browser", - device: "desktop", - background: { - preset: "warm-editor", - }, - browser: { - domain: "launch.example.com", - style: "glass", - }, -} -``` +## Portable Skill -Background presets: +This repo includes a portable Screenstage skill at [skills/screenstage/](./skills/screenstage/). -- `"soft-studio"`: airy neutral green-blue backdrop -- `"warm-editor"`: warmer editorial paper-and-sky mix -- `"cool-stage"`: cooler product-launch backdrop -- `"midnight-fade"`: dark cinematic stage +It is intentionally generic so it can be adapted to other skill-capable agent systems. Start with [skills/screenstage/SKILL.md](./skills/screenstage/SKILL.md). -Browser styles: +## Docs -- `"polished"`: balanced default with fuller chrome and depth -- `"minimal"`: quieter shell with less glow and ornament -- `"glass"`: brighter, more luminous shell treatment - -Phone shell example: - -```js -composition: { - preset: "studio-browser", - device: "phone", - phone: { - color: "#10141a", - }, -} -``` - -For local apps you can add a `serve` block: - -```js -export default { - url: "http://127.0.0.1:3000", - demo: "./demo/starter-demo.mjs", - serve: { - command: "npm run dev", - cwd: ".", - readyText: "ready", - timeoutMs: 30000, - }, -}; -``` - -`serve.command` is started before capture, the tool waits for `url` to respond, and the process is shut down when recording finishes. - -Camera preset example: - -```js -camera: { - preset: "lazy-follow", - zoom: 1.45, -} -``` +- [examples/quickstart/README.md](./examples/quickstart/README.md): bundled example +- [docs/config-reference.md](./docs/config-reference.md): config surface, presets, setup hooks, and record mode +- [docs/authoring.md](./docs/authoring.md): demo authoring, scenes, templates, and runtime helpers +- [docs/cli-contract.md](./docs/cli-contract.md): JSON events, manifest shape, and exit codes +- [docs/for-agents.md](./docs/for-agents.md): agent integration and positioning +- [RELEASING.md](./RELEASING.md): release process +- [CHANGELOG.md](./CHANGELOG.md): release history -## Setup Hooks - -`setup` is the pre-capture state layer. It is meant for getting a real app into the right browser state before recording starts, not for editing the final video. - -Declarative example: - -```js -setup: { - route: "/docs/palette-lab", - query: { - mode: "dark", - panel: "tokens", - }, - colorScheme: "dark", - localStorage: { - "duotone:theme": "dark", - "duotone:last-tab": "tokens", - }, - sessionStorage: { - "motion:capture": "true", - }, - waitFor: { - selector: "[data-ready='true']", - timeoutMs: 10000, - }, -}, -``` - -Optional hook module: - -```js -setup: { - module: "./demo/setup-app.mjs", -}, -``` - -Setup modules export a default async function and receive: - -- `config` -- `context` -- `page` -- `target` -- `sessionDir` -- `url` - -`target` is the actual app surface: -- the page itself in normal captures -- the embedded app frame in studio mode - -Example: - -```js -export default async function setup({ target }) { - await target.click("[data-demo='open-auth-bypass']"); - await target.waitForSelector("[data-demo='dashboard']"); -} -``` - -Manual overrides still win, so you can start from a preset and then tune just one value: - -```js -camera: { - preset: "showcase-follow", - deadzonePx: 28, - smoothingMs: 250, -} -``` - -## Authoring Model - -Your demo module can export either: - -- a default async function for full manual control -- a default scene array for declarative shot sequencing - -If you want repeatable release-style captures, the scene array is now the recommended default. -If you want to avoid hand-building scene arrays for every launch asset, you can also generate them from the built-in templates. -The repo ships with one public example under [examples/quickstart/README.md](./examples/quickstart/README.md) so the default test path stays easy to understand. - -## Manual Record Mode - -`record` is the non-programmatic capture path: - -1. Launch the target app in a visible Chromium window. -2. Inject the polished cursor overlay and recorder controls. -3. Perform the flow manually. -4. Tag camera beats while you record. -5. Finish from the controls or press `Alt+Shift+R`. -6. Get an immediate rendered video plus an editable generated demo file. - -When `browser.studio.enabled` is on, the app is loaded inside a local wrapper page and only the iframe stage is recorded. That is the recommended setup for local dev tools because the controls stay outside the shot while still feeling integrated. - -On machines with `ffmpeg` installed, manual recordings can use one of three paths: -- `video`: the stable default using Playwright's browser-video capture -- `balanced`: JPEG frames plus a high-quality intermediate -- `rgb-frames`: PNG frames plus a lossless RGB intermediate for maximum fidelity - -For real local apps, the current recommendation is: -- `browser.capture.mode: "video"` -- a larger source viewport like `1728x1080` on desktop -- `output.preset: "motion-edit"` with both `mp4` and `prores` - -Built-in shot markers: - -- `Alt+Shift+1`: `Wide` to pull back out -- `Alt+Shift+2`: `Punch In` to zoom in and follow the cursor -- `Alt+Shift+3`: `Hold` to insert a camera hold in the generated demo script - -The recorder captures: - -- cursor movement samples for the rendered follow camera -- clicks -- typing -- wheel scrolling -- manual shot markers -- inferred selectors when it can identify stable targets - -Manual record sessions save these extra artifacts: - -- `recording.json`: raw captured actions and cursor samples -- `generated-demo.mjs`: a generated runnable demo module in the session folder -- `*.recorded-.mjs`: a copy of that generated demo saved next to your configured demo file so you can edit and reuse it - -The generated demo file is intentionally conservative. It aims to be easy to tweak, not to perfectly recreate every millisecond of the original interaction. When you place shot markers during recording, the generator emits camera calls like `camera.zoomTo()` and `camera.zoomOut()` around the recorded interactions instead of leaving all of the cinematic timing for later. - -## Demo Runtime API - -Async demo functions receive: - -- `page`: the Playwright page -- `cursor`: visible cursor controller -- `camera`: framing controller for post-processing -- `config`: resolved runtime config -- `sessionDir`: output directory for the active run - -Manual example: - -```js -export default async function demo({ camera, cursor }) { - await cursor.moveToSelector("[data-demo='card-1']", { - durationMs: 950, - camera: { - follow: true, - zoomFrom: 1, - zoomTo: 1.7, - }, - }); - await camera.wait(250); - await cursor.moveToSelector("[data-demo='cta']", { - durationMs: 800, - camera: { - follow: true, - zoomFrom: 1.7, - zoomTo: 1.9, - }, - }); - await cursor.click(); - await camera.zoomOut({ durationMs: 700, followCursor: true }); -} -``` - -You can also stage the camera timing inside a single move so long travel stays wide until the approach: - -```js -await cursor.moveToSelector("[data-demo='search']", { - durationMs: 1600, - camera: { - follow: true, - timingPreset: "late-arrival", - zoomFrom: 1, - zoomTo: 1.8, - }, -}); -``` - -`followStart` / `followEnd` and `zoomStart` / `zoomEnd` are normalized move-progress values from `0` to `1`. -If you want the ratios explicitly, you can still override them on top of a preset. - -Built-in move timing presets: - -- `"continuous"`: follow and zoom through the whole move -- `"late-arrival"`: stay wide through early travel, tighten near arrival -- `"depart-reveal"`: pull out early as the cursor departs, then travel broader -- `"settle"`: slower handoff suited to small local corrections or hover-heavy moves - -## Scene API - -Scene programs are exported as a plain array: - -```js -export default [ - { - type: "wide", - durationMs: 400, - label: "Start on a broad establishing shot", - }, - { - type: "focus-selector", - selector: "[data-demo='email']", - durationMs: 850, - zoom: 2, - }, - { - type: "type-selector", - selector: "[data-demo='email']", - text: "hello@email.com", - durationMs: 900, - delayMs: 75, - }, - { - type: "follow-cursor", - durationMs: 300, - }, - { - type: "move-selector", - selector: "[data-demo='cta']", - durationMs: 850, - cameraFollow: true, - timingPreset: "late-arrival", - zoomFrom: 1, - zoomTo: 1.9, - }, - { - type: "click", - }, - { - type: "zoom-out", - durationMs: 700, - followCursor: true, - }, - { - type: "wait", - durationMs: 800, - target: "camera", - }, -]; -``` - -Supported scene types: - -- `wide` -- `follow-cursor` -- `focus-selector` -- `focus-point` -- `move-selector` -- `move-point` -- `zoom-to` -- `zoom-out` -- `click` -- `click-selector` -- `type` -- `type-selector` -- `wait` - -`wait.target` accepts `"cursor"`, `"camera"`, or `"both"`. - -## Templates - -The template helpers generate scene arrays for common release/demo flows: - -- `createFeatureTour()`: a traveling product tour that can keep the camera attached to cursor moves between distant selectors -- `createFormFillCapture()`: a steadier form-entry flow with optional submit, suited to static or lightly directed framing -- `createHeroWalkthrough()`: a release-style sequence that opens wide, converts in the hero area, then reveals a proof point - -Example: - -```js -import { createHeroWalkthrough } from "screenstage"; - -export default createHeroWalkthrough({ - fieldSelector: "[data-demo='email']", - fieldText: "hello@getrestocky.com", - ctaSelector: "[data-demo='cta']", - metricSelector: "[data-demo='card-2']", -}); -``` - -For a more general selector tour: - -```js -import { createFeatureTour } from "screenstage"; - -export default createFeatureTour({ - introPauseMs: 500, - steps: [ - { - selector: "[data-demo='email']", - action: "type", - text: "hello@email.com", - zoom: 2, - }, - { - selector: "[data-demo='cta']", - action: "click", - zoom: 1.8, - }, - { - selector: "[data-demo='card-2']", - action: "move", - cameraFollow: true, - zoom: 1.8, - pauseMs: 900, - pauseTarget: "camera", - }, - ], -}); -``` - -### Cursor Helpers - -- `cursor.move({ x, y }, options)` -- `cursor.moveToSelector(selector, options)` -- `cursor.click(options)` -- `cursor.clickSelector(selector, options)` -- `cursor.type(text, options)` -- `cursor.typeSelector(selector, text, options)` -- `cursor.wait(durationMs)` -- `cursor.sample(kind)` - -You can also import move timing helpers directly: - -```js -import { createCameraMoveTiming } from "screenstage"; - -await cursor.moveToSelector("[data-demo='search']", { - durationMs: 1600, - camera: { - follow: true, - zoomFrom: 1, - zoomTo: 1.8, - ...createCameraMoveTiming("late-arrival", { - followEnd: 0.96, - }), - }, -}); -``` - -### Camera Helpers - -- `camera.focus({ x, y }, options)` -- `camera.focusSelector(selector, options)` -- `camera.followCursor(options)` -- `camera.wide(options)` -- `camera.wait(durationMs)` -- `camera.sample(kind)` - -If you want a traditional non-followed browser recording with the composition shell still applied, set: - -```js -camera: { - mode: "static", - zoom: 1, -} -``` +## License -## Recommended Workflow - -For strong release-style captures: - -- Use a source viewport around `1440x900` and render at `1920x1080`. -- Use `output.preset` for repeatable delivery targets instead of hand-tuning every new config. -- Pause deliberately with `cursor.wait()` or `camera.wait()` so the camera has time to settle. -- Use `camera.focusSelector()` before important interactions instead of letting every shot be cursor-led. -- Increase `camera.smoothingMs` if a cursor-led sequence still feels twitchy, or reduce it if the camera feels too lazy. -- Switch `camera.mode` to `"static"` when you want a composed showcase clip that behaves like a normal screen recording. -- Use `cursor.typeSelector()` for form entries so the footage reads like a real person using the app. -- Export `prores` when the clip is headed into Motion or Final Cut for finishing. - -## Current Scope - -Implemented now: - -- real browser recording -- realistic cursor overlay -- hover-aware cursor variants -- manual camera keyframes -- declarative scene arrays -- local dev-server lifecycle -- browser-shell composition presets -- smoother cursor-led camera tracking -- reusable output presets -- reusable scene templates -- poster frame + contact sheet review artifacts -- MP4 + ProRes rendering -- starter scaffold - -Not implemented yet: - -- Remotion pipeline -- transparent-alpha export -- timeline editor / scene DSL beyond the current script API +MIT. See [LICENSE](./LICENSE). diff --git a/skills/screenstage/SKILL.md b/skills/screenstage/SKILL.md new file mode 100644 index 0000000..58d8aab --- /dev/null +++ b/skills/screenstage/SKILL.md @@ -0,0 +1,144 @@ +--- +name: screenstage +description: Capture polished browser demo videos from local apps, static pages, or deployed URLs with the Screenstage CLI. Use when an agent needs to record a product walkthrough, launch clip, changelog demo, onboarding flow, bug repro video, or other browser-based video artifact from a web app. Trigger on requests to record, render, capture, or generate a browser demo video, especially when the target is localhost, a staging site, or an app in the current repo. +--- + +# Screenstage + +Use Screenstage as the final capture layer for browser-based product videos. + +Prefer it when the user wants a polished output artifact, not just browser automation. The core job is: point Screenstage at the right app state, choose `run` or `record`, execute the capture, then hand the caller the manifest and output video paths. + +## Quick Start + +Use `screenstage run` for scripted, repeatable captures. + +Use `screenstage record` for the headed human workflow, especially when: + +- the user wants to control the mouse live in the browser +- the camera and cursor movement should come from a human-run session instead of a scripted demo +- you want the same output artifacts, but with the motion captured from a live session + +Use `screenstage init` when the repo does not already have a usable `screenstage.config.mjs`. + +For agent-safe runs, prefer: + +```bash +screenstage run ./path/to/screenstage.config.mjs --json +screenstage record ./path/to/screenstage.config.mjs --json +screenstage init ./demo-project --yes +``` + +## Workflow + +### 1. Find or create the config + +Look for an existing `screenstage.config.mjs` first. + +If none exists, run: + +```bash +screenstage init ./demo-project --yes +``` + +Then adapt the generated config to the target app. + +For concrete config shapes, read [config-patterns.md](./references/config-patterns.md). + +### 2. Point Screenstage at the right target + +Choose one of these target modes: + +- local dev server already running +- local dev server that Screenstage should start with `serve.command` +- static `file://` target for fixture or demo HTML +- deployed or staging URL + +If the repo already has a dev command, prefer wiring that into config instead of asking the user to start a server manually. + +### 3. Choose `run` or `record` + +Choose `run` when: + +- the capture should be reproducible +- the flow can be scripted +- the user will likely rerun it during iteration + +Choose `record` when: + +- the human should steer the browser live in the headed studio workflow +- the cursor and camera motion should come from the live recording session instead of a preprogrammed demo +- the user still wants the same rendered output artifacts afterward + +### 4. Prefer machine-facing execution + +When acting as an agent, prefer JSON mode and explicit overrides: + +```bash +screenstage run ./screenstage.config.mjs --json --output-dir ./tmp/screenstage +screenstage record ./screenstage.config.mjs --json --output-dir ./tmp/screenstage --visible +``` + +Use `--headless` for unattended scripted runs unless visible mode is necessary for the task. + +### 5. Read the result from the manifest + +Do not scrape prose logs if `manifest.json` exists. + +Use: + +- `command_completed` or `command_failed` as the terminal JSON event +- `manifestPath` as the canonical handoff +- manifest artifact paths to locate `final.mp4`, `poster.png`, `contact-sheet.png`, `recording.json`, and generated demo files + +If an artifact path in the manifest is relative, resolve it from the session directory. If it is absolute, use it as-is. + +For the exact CLI and manifest contract, read [../../docs/cli-contract.md](../../docs/cli-contract.md). + +## Output Expectations + +Expect these outcomes from successful runs: + +- `run` usually produces `final.mp4` plus manifest and marker artifacts +- `record` also produces `recording.json` and generated demo files +- the rendered outputs are the same class of artifacts; the difference is whether motion came from a scripted demo (`run`) or a human-headed live session (`record`) +- if `ffmpeg` is missing, Screenstage can still produce partial outputs and a manifest with a partial completion state + +Prefer returning these to the user: + +- the main video artifact path +- the manifest path +- one sentence on whether the run was full success, partial success, or cancelled + +## Failure Handling + +Map failures to the structured contract first: + +- exit code `2`: invalid arguments or config +- exit code `3`: target unavailable +- exit code `4`: browser failure +- exit code `5`: capture failure +- exit code `6`: render failure +- exit code `7`: missing dependency + +When a run fails, inspect: + +- the CLI JSON `command_failed` event +- the config target and output overrides +- whether Playwright Chromium and `ffmpeg` are available +- whether the app became reachable before timeout + +For common fixes, read [troubleshooting.md](./references/troubleshooting.md). + +## Good Defaults + +Prefer these defaults unless the task suggests otherwise: + +- `run` over `record` for repeatable feature demos +- `record` when the user explicitly wants to drive the browser live in studio mode +- `--json` for agent execution +- `--output-dir` to keep artifacts isolated from user-owned folders during iteration +- `--headless` for automated runs +- the manifest over ad hoc file guessing + +When the user asks for a polished clip but does not specify styling, keep the existing Screenstage presets rather than inventing a custom visual treatment. diff --git a/skills/screenstage/agents/openai.yaml b/skills/screenstage/agents/openai.yaml new file mode 100644 index 0000000..89cfb9b --- /dev/null +++ b/skills/screenstage/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Screenstage" + short_description: "Capture polished browser demo videos" + default_prompt: "Use $screenstage to capture a polished demo video for this web app." diff --git a/skills/screenstage/references/config-patterns.md b/skills/screenstage/references/config-patterns.md new file mode 100644 index 0000000..52fb83e --- /dev/null +++ b/skills/screenstage/references/config-patterns.md @@ -0,0 +1,134 @@ +# Config Patterns + +Use these patterns when adapting or creating `screenstage.config.mjs`. + +## 1. Local App With Existing Dev Server + +Use this when the app is already running and the user wants a quick capture. + +```js +export default { + name: "dashboard-demo", + url: "http://127.0.0.1:3000", + demo: "./demo/dashboard-demo.mjs", + output: { + dir: "./output", + preset: "release-hero", + }, + browser: { + headless: true, + }, +}; +``` + +## 2. Local App Started By Screenstage + +Use this when the repo has a known dev command and the app should be booted as part of capture. + +```js +export default { + name: "dashboard-demo", + url: "http://127.0.0.1:3000", + demo: "./demo/dashboard-demo.mjs", + serve: { + command: "npm run dev", + readyText: "ready", + timeoutMs: 30000, + }, + output: { + dir: "./output", + preset: "release-hero", + }, +}; +``` + +Tips: + +- prefer `readyText` when the dev server logs a stable readiness line +- keep `url` explicit even when `serve.command` is present +- if the app binds a different port, match it exactly + +## 3. Static Demo Or Fixture HTML + +Use this when the repo contains a static page, fixture, or demo site. + +```js +export default { + name: "static-demo", + url: new URL("./demo-site/index.html", import.meta.url).href, + demo: "./demo/static-demo.mjs", + browser: { + headless: true, + }, +}; +``` + +This is the simplest path for smoke tests and documentation examples. + +## 4. Staging Or Production URL + +Use this when the app state already exists on a deployed environment. + +```js +export default { + name: "staging-demo", + url: "https://staging.example.com", + demo: "./demo/staging-demo.mjs", + output: { + dir: "./output", + preset: "release-hero", + }, +}; +``` + +Add setup if the page needs query params, routing, or preloaded storage before capture. + +## 5. Agent-Friendly Run Overrides + +Prefer CLI overrides instead of rewriting config when the change is ephemeral. + +Examples: + +```bash +screenstage run ./screenstage.config.mjs --json --output-dir ./tmp/screenstage +screenstage record ./screenstage.config.mjs --json --visible +screenstage run ./screenstage.config.mjs --json --headless +``` + +Use these for: + +- isolated temp outputs +- switching visible versus headless behavior +- keeping user-owned config stable during agent iteration + +## 6. Choosing `run` Versus `record` + +Use `run` when you can script the flow cleanly. + +Good fits: + +- homepage walkthrough +- launch clip +- feature changelog demo +- onboarding path that will be rerun + +Use `record` when the flow is easier to perform live or the user wants to refine the generated demo afterward. + +Good fits: + +- exploratory UX walkthrough +- manual bug repro capture +- quick product narrative assembled live + +## 7. Output And Composition Defaults + +Use built-in presets unless the user has a specific delivery target. + +Good starting points: + +- `release-hero` for normal landscape demos +- `social-square` for square exports +- `social-vertical` for portrait exports +- `studio-browser` for a polished browser-shell presentation + +Avoid hand-tuning widths, heights, and camera parameters unless the preset is clearly wrong for the task. diff --git a/skills/screenstage/references/troubleshooting.md b/skills/screenstage/references/troubleshooting.md new file mode 100644 index 0000000..e371c43 --- /dev/null +++ b/skills/screenstage/references/troubleshooting.md @@ -0,0 +1,126 @@ +# Troubleshooting + +Use this file when Screenstage fails or produces partial output. + +## Config Or Argument Failures + +Symptoms: + +- exit code `2` +- `command_failed` with `INVALID_ARGUMENTS` + +Check: + +- config path exists +- config exports a default object +- `url` is present and non-empty +- `demo` path is present and valid +- CLI flags were passed in the supported order and format + +## Target Unavailable + +Symptoms: + +- exit code `3` +- timeout waiting for the app URL +- serve command exits before the app is ready + +Check: + +- target URL is correct +- port matches the real app +- `serve.command` works outside Screenstage +- `readyText` actually appears in the dev server logs +- the timeout is long enough for the app to boot + +## Browser Failures + +Symptoms: + +- exit code `4` +- Chromium launch failure + +Check: + +- Playwright browser runtime is installed +- the chosen browser channel exists +- headless versus visible mode is appropriate for the environment + +Typical fix: + +```bash +npx playwright install chromium +``` + +## Capture Failures + +Symptoms: + +- exit code `5` +- no source video produced +- manual recording did not complete + +Check: + +- the page reached a stable loaded state +- the automation path actually finished the manual recorder +- the browser stayed open for the full session + +If using `record` programmatically with automation, ensure the automation explicitly finishes or cancels the recorder. + +## Render Failures + +Symptoms: + +- exit code `6` +- capture succeeded but final outputs were not rendered + +Check: + +- `ffmpeg` exists on `PATH` +- there is enough disk space in the output directory +- the intermediate source video exists + +If the run ends as partial success, use the manifest and source artifact paths to inspect what was produced. + +## Missing Dependency + +Symptoms: + +- exit code `7` +- `ffmpeg` not found + +Check: + +- `ffmpeg` is installed and reachable in the current shell environment + +Typical fix: + +```bash +ffmpeg -version +``` + +If that fails, install FFmpeg before retrying. + +## Manifest Interpretation + +When the run succeeded or partially succeeded, inspect: + +- `manifest.json` +- `artifacts.source` +- `artifacts.finalRenders` +- `artifacts.recording` +- `artifacts.generatedDemo` + +Do not guess file names if the manifest already exists. + +## Practical Recovery Order + +When debugging, use this order: + +1. inspect the terminal JSON event and exit code +2. inspect the manifest if present +3. confirm the target app was reachable +4. confirm Playwright Chromium is installed +5. confirm `ffmpeg` is available +6. retry with `--output-dir` pointing at a fresh temp folder diff --git a/src/cli.ts b/src/cli.ts index 4aaa876..1c1ea25 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,60 +1,201 @@ import { initProject } from "./init.js"; +import { classifyCliError, EXIT_CODES, ScreenstageError } from "./errors.js"; import { recordMotion } from "./record.js"; +import { createHumanReporter, createJsonReporter } from "./reporter.js"; import { runMotion } from "./run.js"; function printHelp(): void { console.log(`screenstage Usage: - screenstage init [directory] - screenstage record - screenstage run + screenstage init [directory] [--yes] + screenstage record [--json] [--output-dir ] [--headless|--visible] + screenstage run [--json] [--output-dir ] [--headless|--visible] Commands: init Run the guided config wizard or scaffold a starter project in non-interactive shells. record Capture a manual browser session and generate an editable demo file. run Record a demo session and generate an FFmpeg follow-cam render. + +Options: + --json Emit newline-delimited JSON events for machine consumption. + --output-dir Override the configured output directory for this command. + --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. `); } -async function main(): Promise { - const command = process.argv[2]; - const value = process.argv[3]; +type ParsedCliArgs = { + command?: string; + headless?: boolean; + json: boolean; + nonInteractive: boolean; + outputDir?: string; + value?: string; +}; - if (!command || command === "--help" || command === "-h") { - printHelp(); - return; - } +function parseArgs(argv: string[]): ParsedCliArgs { + const args = [...argv]; + const command = args.shift(); + let value: string | undefined; + let outputDir: string | undefined; + let json = false; + let headless: boolean | undefined; + let nonInteractive = false; - if (command === "init") { - await initProject(value); - console.log("Init complete."); - return; - } + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; - if (command === "run") { - if (!value) { - throw new Error("Usage: screenstage run "); + if (arg === "--json") { + json = true; + continue; } - await runMotion(value); - return; - } + if (arg === "--yes" || arg === "--non-interactive") { + nonInteractive = true; + continue; + } + + if (arg === "--headless") { + headless = true; + continue; + } + + if (arg === "--visible") { + headless = false; + continue; + } + + if (arg === "--output-dir") { + const next = args[index + 1]; + + if (!next || next.startsWith("-")) { + throw new ScreenstageError( + "INVALID_ARGUMENTS", + "Usage: --output-dir ", + ); + } + + outputDir = next; + index += 1; + continue; + } + + if (arg.startsWith("-")) { + throw new ScreenstageError( + "INVALID_ARGUMENTS", + `Unsupported option '${arg}'.`, + ); + } - if (command === "record") { if (!value) { - throw new Error("Usage: screenstage record "); + value = arg; + continue; } - await recordMotion(value); - return; + throw new ScreenstageError( + "INVALID_ARGUMENTS", + `Unexpected positional argument '${arg}'.`, + ); } - throw new Error(`Unknown command '${command}'.`); + return { + command, + headless, + json, + nonInteractive, + outputDir, + value, + }; +} + +async function main(): Promise { + const parsedArgs = parseArgs(process.argv.slice(2)); + const reporter = parsedArgs.json ? createJsonReporter() : createHumanReporter(); + + try { + const { command, headless, json, nonInteractive, outputDir, value } = parsedArgs; + + if (!command || command === "--help" || command === "-h") { + printHelp(); + return; + } + + if (command === "init") { + if (json || outputDir || headless !== undefined) { + throw new ScreenstageError( + "INVALID_ARGUMENTS", + "`init` only supports [directory] and `--yes`.", + ); + } + + await initProject(value, { nonInteractive }); + console.log("Init complete."); + return; + } + + if (command === "run") { + if (!value) { + throw new ScreenstageError( + "INVALID_ARGUMENTS", + "Usage: screenstage run [--json]", + ); + } + + await runMotion(value, { + configOverrides: { + headless, + outputDir, + }, + reporter, + }); + return; + } + + if (command === "record") { + if (!value) { + throw new ScreenstageError( + "INVALID_ARGUMENTS", + "Usage: screenstage record [--json]", + ); + } + + await recordMotion(value, { + configOverrides: { + headless, + outputDir, + }, + reporter, + }); + return; + } + + throw new ScreenstageError( + "INVALID_ARGUMENTS", + `Unknown command '${command}'.`, + ); + } catch (error) { + const failure = classifyCliError(error); + + if ( + parsedArgs.json && + (parsedArgs.command === "run" || parsedArgs.command === "record") + ) { + reporter.emit({ + code: failure.code, + command: parsedArgs.command, + details: failure.details, + event: "command_failed", + exitCode: failure.exitCode, + message: failure.message, + }); + } else { + process.stderr.write(`${failure.message}\n`); + } + + process.exitCode = failure.exitCode ?? EXIT_CODES.unknown; + } } -main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - console.error(message); - process.exitCode = 1; -}); +main(); diff --git a/src/config.ts b/src/config.ts index ec00940..79dff94 100644 --- a/src/config.ts +++ b/src/config.ts @@ -249,6 +249,11 @@ function resolveBrowserDomain(url: string): string | undefined { } } +export type LoadConfigOverrides = { + headless?: boolean; + outputDir?: string; +}; + function normalizeSetupQuery( value: Record | undefined, ): Record { @@ -261,7 +266,10 @@ function normalizeSetupQuery( ) as Record; } -export async function loadConfig(configPath: string): Promise { +export async function loadConfig( + configPath: string, + overrides: LoadConfigOverrides = {}, +): Promise { const absoluteConfigPath = path.resolve(configPath); const configModule = await import(pathToFileURL(absoluteConfigPath).href); const config = asMotionConfig(configModule); @@ -397,7 +405,8 @@ export async function loadConfig(configPath: string): Promise = { + BROWSER_FAILURE: EXIT_CODES.browserFailure, + CAPTURE_FAILURE: EXIT_CODES.captureFailure, + DEPENDENCY_MISSING: EXIT_CODES.dependencyMissing, + INVALID_ARGUMENTS: EXIT_CODES.invalidArguments, + RENDER_FAILURE: EXIT_CODES.renderFailure, + TARGET_UNAVAILABLE: EXIT_CODES.targetUnavailable, +}; + +export class ScreenstageError extends Error { + readonly code: ScreenstageErrorCode; + + readonly exitCode: ExitCode; + + readonly details?: Record; + + constructor( + code: ScreenstageErrorCode, + message: string, + details?: Record, + ) { + super(message); + this.name = "ScreenstageError"; + this.code = code; + this.exitCode = EXIT_CODE_BY_ERROR[code]; + this.details = details; + } +} + +export function classifyCliError(error: unknown): { + code?: ScreenstageErrorCode; + details?: Record; + exitCode: ExitCode; + message: string; +} { + if (error instanceof ScreenstageError) { + return { + code: error.code, + details: error.details, + exitCode: error.exitCode, + message: error.message, + }; + } + + const message = error instanceof Error ? error.message : String(error); + + if ( + message.startsWith("Usage:") || + message.startsWith("Unknown command") || + message.startsWith("Unsupported option") || + message.includes("Config module") || + message.includes("config") + ) { + return { + code: "INVALID_ARGUMENTS", + exitCode: EXIT_CODES.invalidArguments, + message, + }; + } + + if ( + message.includes("Timed out waiting for") || + message.includes("app was ready") + ) { + return { + code: "TARGET_UNAVAILABLE", + exitCode: EXIT_CODES.targetUnavailable, + message, + }; + } + + if ( + message.includes("ffmpeg") && + (message.includes("not found") || message.includes("PATH")) + ) { + return { + code: "DEPENDENCY_MISSING", + exitCode: EXIT_CODES.dependencyMissing, + message, + }; + } + + if ( + message.includes("Playwright did not produce a source video") || + message.includes("did not complete") + ) { + return { + code: "CAPTURE_FAILURE", + exitCode: EXIT_CODES.captureFailure, + message, + }; + } + + if ( + message.includes("browserType.launch") || + message.includes("Executable doesn't exist") || + message.includes("Failed to launch") || + message.includes("Target page, context or browser has been closed") + ) { + return { + code: "BROWSER_FAILURE", + exitCode: EXIT_CODES.browserFailure, + message, + }; + } + + if (message.includes("Command failed: ffmpeg")) { + return { + code: "RENDER_FAILURE", + exitCode: EXIT_CODES.renderFailure, + message, + }; + } + + return { + exitCode: EXIT_CODES.unknown, + message, + }; +} diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts index ee7cfb5..e6579dc 100644 --- a/src/ffmpeg.ts +++ b/src/ffmpeg.ts @@ -528,8 +528,14 @@ function getFormatOutputPath( function renderFfmpegArgs(args: string[]): Promise { return new Promise((resolve, reject) => { - const child = spawn("ffmpeg", args, { - stdio: "inherit", + const child = spawn("ffmpeg", [ + "-hide_banner", + "-loglevel", + "error", + "-nostats", + ...args, + ], { + stdio: ["ignore", "ignore", "inherit"], }); child.once("error", (error) => reject(error)); diff --git a/src/index.ts b/src/index.ts index 92c7f06..4912815 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,15 @@ export { } from "./camera-timing.js"; export { defineConfig } from "./config.js"; export { defineScenes } from "./scenes.js"; +export type { LoadConfigOverrides } from "./config.js"; +export type { RecordMotionOptions, RecordMotionResult } from "./record.js"; +export type { RunMotionOptions, RunMotionResult } from "./run.js"; +export type { + ManifestArtifact, + ManifestArtifacts, + SessionManifest, + SessionManifestMode, +} from "./session-manifest.js"; export { createFeatureTour, createFormFillCapture, diff --git a/src/init.ts b/src/init.ts index 329882c..8a94717 100644 --- a/src/init.ts +++ b/src/init.ts @@ -24,6 +24,10 @@ type InitAnswers = { url: string; }; +export type InitProjectOptions = { + nonInteractive?: boolean; +}; + const CONFIG_TEMPLATE = `export default { name: "starter-workspace", url: new URL("./demo-site/index.html", import.meta.url).href, @@ -1112,8 +1116,11 @@ async function writeGuidedProject(answers: InitAnswers): Promise { } } -export async function initProject(directoryArg = "."): Promise { - if (!canPrompt()) { +export async function initProject( + directoryArg = ".", + options: InitProjectOptions = {}, +): Promise { + if (options.nonInteractive || !canPrompt()) { await writeStarterProject(path.resolve(directoryArg)); return; } diff --git a/src/record.ts b/src/record.ts index b580148..c848077 100644 --- a/src/record.ts +++ b/src/record.ts @@ -5,7 +5,9 @@ import type { BrowserContext, Page } from "playwright"; import { chromium } from "playwright"; import { prepareComposition } from "./composition.js"; +import type { LoadConfigOverrides } from "./config.js"; import { loadConfig } from "./config.js"; +import { ScreenstageError } from "./errors.js"; import { assembleFramesToVideo, buildFfmpegPlans, @@ -26,21 +28,36 @@ import { } from "./manual-recorder.js"; import { buildManualEditMarkers, writeMarkerArtifacts } from "./markers.js"; import { buildGeneratedDemoSource } from "./record-script.js"; -import { startManagedService } from "./serve.js"; -import { artifact, writeSessionManifest } from "./session-manifest.js"; +import type { Reporter } from "./reporter.js"; +import { startManagedService, type ManagedService } from "./serve.js"; +import { + artifact, + writeSessionManifest, + type SessionManifest, +} from "./session-manifest.js"; import { applyContextSetup, applyPageEmulation, applyPageSetup, resolveCaptureUrl } from "./setup.js"; import { startStudioSession } from "./studio.js"; import type { CameraSample, CursorSample, Point } from "./types.js"; import { installCursorOverlay, moveCursorOverlay } from "./cursor-overlay.js"; type StorageState = Awaited>; +type BrowserInstance = Awaited>; -type RecordMotionOptions = { +export type RecordMotionOptions = { automation?: ( page: Page, controls: ManualRecorderControls, ) => Promise; - headless?: boolean; + configOverrides?: LoadConfigOverrides; + reporter?: Reporter; +}; + +export type RecordMotionResult = { + captureUrl: string; + manifest?: SessionManifest; + manifestPath?: string; + recordingPath: string; + sessionDir: string; }; type ManualControllerWindow = { @@ -50,7 +67,7 @@ type ManualControllerWindow = { }; async function openManualRecorderController( - browser: ReturnType extends Promise ? T : never, + browser: BrowserInstance, controls: ManualRecorderControls, recordingPage: Page, ): Promise { @@ -586,8 +603,10 @@ function buildGeneratedDemoFilePath( export async function recordMotion( configPath: string, options: RecordMotionOptions = {}, -): Promise { - const config = await loadConfig(configPath); +): Promise { + const reporter = options.reporter; + const startedAt = Date.now(); + const config = await loadConfig(configPath, options.configOverrides); const captureUrl = resolveCaptureUrl(config); const ffmpegAvailable = await commandExists("ffmpeg"); const requestedCaptureMode = config.browser.capture.mode; @@ -595,7 +614,7 @@ export async function recordMotion( const useFrameCapture = effectiveCaptureMode !== "video"; if (!ffmpegAvailable && requestedCaptureMode !== "video") { - console.warn( + reporter?.log( `ffmpeg was not available, so record fell back to browser video capture instead of '${requestedCaptureMode}'.`, ); } @@ -615,16 +634,56 @@ export async function recordMotion( config.demoPath, sessionName, ); - const managedService = await startManagedService(config); + let managedService: ManagedService | undefined; const studioSession = await startStudioSession(config, captureUrl); const navigationUrl = studioSession?.wrapperUrl ?? captureUrl; + let manifest: SessionManifest | undefined; + let manifestPath: string | undefined; + + reporter?.emit({ + command: "record", + configPath: config.configPath, + event: "command_started", + outputDir: config.output.dir, + sessionDir, + }); + + managedService = await startManagedService(config); + + if (managedService) { + reporter?.emit({ + command: "record", + configPath: config.configPath, + event: "service_started", + targetUrl: captureUrl, + }); + } await fs.mkdir(recordingsDir, { recursive: true }); - const browser = await chromium.launch({ - channel: config.browser.channel, - headless: options.headless ?? false, - slowMo: config.browser.slowMo, + let browser: BrowserInstance; + 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: "record", + event: "browser_started", + headless: config.browser.headless, }); let storageState: StorageState | undefined; @@ -703,6 +762,13 @@ export async function recordMotion( let frameCapture: ManualFrameCapture | undefined; try { + reporter?.emit({ + captureUrl, + command: "record", + event: "capture_started", + viewport: config.viewport, + }); + await page.goto(navigationUrl, { waitUntil: "load" }); let controls: ManualRecorderControls; @@ -844,34 +910,86 @@ export async function recordMotion( const recordedVideoPath = await video?.path(); if (!recording) { - throw new Error("Manual recording did not complete."); + throw new ScreenstageError( + "CAPTURE_FAILURE", + "Manual recording did not complete.", + ); } if (frameCapture) { - await assembleFramesToVideo(frameCapture.frames, sourceVideoPath, recording.durationMs); + try { + await assembleFramesToVideo( + frameCapture.frames, + sourceVideoPath, + recording.durationMs, + ); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + artifact: "source-video", + outputPath: sourceVideoPath, + }, + ); + } } else { if (!recordedVideoPath) { - throw new Error("Playwright did not produce a source video."); + throw new ScreenstageError( + "CAPTURE_FAILURE", + "Playwright did not produce a source video.", + ); } await fs.rename(recordedVideoPath, fallbackVideoPath); } await fs.writeFile(recordingPath, JSON.stringify(recording, null, 2), "utf8"); + reporter?.emit({ + command: "record", + event: "capture_completed", + recordingPath, + sourceVideoPath: frameCapture ? sourceVideoPath : fallbackVideoPath, + }); if (recording.cancelled) { - console.log(`Recording cancelled. Raw capture saved to ${recordingPath}`); - return; + reporter?.log(`Recording cancelled. Raw capture saved to ${recordingPath}`); + reporter?.emit({ + command: "record", + durationMs: Date.now() - startedAt, + event: "command_completed", + recordingPath, + sessionDir, + status: "cancelled", + }); + return { + captureUrl, + recordingPath, + sessionDir, + }; } const renderSourcePath = useFrameCapture && frameCapture ? sourceVideoPath : studioSession - ? (await cropVideoSource( - fallbackVideoPath, - croppedSourceVideoPath, - studioSession.captureRegion, - ), + ? ((await (async () => { + try { + await cropVideoSource( + fallbackVideoPath, + croppedSourceVideoPath, + studioSession.captureRegion, + ); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + artifact: "cropped-source-video", + outputPath: croppedSourceVideoPath, + }, + ); + } + })()), croppedSourceVideoPath) : fallbackVideoPath; @@ -902,8 +1020,6 @@ export async function recordMotion( config.output.fps, editMarkers, ); - let manifestPath: string | undefined; - await fs.writeFile( path.join(sessionDir, "timeline.json"), JSON.stringify( @@ -935,12 +1051,32 @@ export async function recordMotion( "utf8", ); - console.log(`Generated editable demo: ${editableGeneratedDemoPath}`); + reporter?.log(`Generated editable demo: ${editableGeneratedDemoPath}`); if (ffmpegAvailable) { + reporter?.emit({ + command: "record", + event: "render_started", + plannedOutputs: ffmpegPlans.map((plan) => ({ + format: plan.format, + outputPath: plan.outputPath, + })), + }); + for (const plan of ffmpegPlans) { - await renderWithFfmpeg(plan); - console.log(`Rendered ${plan.format} video: ${plan.outputPath}`); + try { + await renderWithFfmpeg(plan); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + format: plan.format, + outputPath: plan.outputPath, + }, + ); + } + reporter?.log(`Rendered ${plan.format} video: ${plan.outputPath}`); } const reviewSourcePath = @@ -955,18 +1091,51 @@ export async function recordMotion( posterPath = path.join(sessionDir, "poster.png"); contactSheetPath = path.join(sessionDir, "contact-sheet.png"); - await renderPosterFrame(reviewSourcePath, posterPath, durationSeconds); - console.log(`Rendered poster frame: ${posterPath}`); - - await renderContactSheet(reviewSourcePath, contactSheetPath, durationSeconds); - console.log(`Rendered contact sheet: ${contactSheetPath}`); - - markerStillPaths = await renderMarkerStills( - reviewSourcePath, - path.join(sessionDir, "markers"), - editMarkers, - durationSeconds, - ); + try { + await renderPosterFrame(reviewSourcePath, posterPath, durationSeconds); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + artifact: "poster", + outputPath: posterPath, + }, + ); + } + reporter?.log(`Rendered poster frame: ${posterPath}`); + + try { + await renderContactSheet(reviewSourcePath, contactSheetPath, durationSeconds); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + artifact: "contact-sheet", + outputPath: contactSheetPath, + }, + ); + } + reporter?.log(`Rendered contact sheet: ${contactSheetPath}`); + + try { + markerStillPaths = await renderMarkerStills( + reviewSourcePath, + path.join(sessionDir, "markers"), + editMarkers, + durationSeconds, + ); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + artifact: "marker-stills", + outputDir: path.join(sessionDir, "markers"), + }, + ); + } } manifestPath = await writeSessionManifest({ @@ -1007,6 +1176,7 @@ export async function recordMotion( mode: "manual-record", sessionDir, }); + manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as SessionManifest; await fs.writeFile( path.join(sessionDir, "timeline.json"), @@ -1039,11 +1209,36 @@ export async function recordMotion( "utf8", ); - if (manifestPath) { - console.log(`Wrote session manifest: ${manifestPath}`); - } + reporter?.emit({ + artifacts: manifest.artifacts, + command: "record", + event: "artifacts_written", + manifestPath, + sessionDir, + }); + reporter?.emit({ + command: "record", + durationMs: Date.now() - startedAt, + event: "render_completed", + manifestPath, + }); + reporter?.log(`Wrote session manifest: ${manifestPath}`); + reporter?.emit({ + command: "record", + durationMs: Date.now() - startedAt, + event: "command_completed", + manifestPath, + sessionDir, + status: "success", + }); - return; + return { + captureUrl, + manifest, + manifestPath, + recordingPath, + sessionDir, + }; } manifestPath = await writeSessionManifest({ @@ -1070,6 +1265,7 @@ export async function recordMotion( mode: "manual-record", sessionDir, }); + manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as SessionManifest; await fs.writeFile( path.join(sessionDir, "timeline.json"), @@ -1102,11 +1298,36 @@ export async function recordMotion( "utf8", ); - console.log("ffmpeg was not found on PATH. Generated plan only."); - console.log(`Source video: ${renderSourcePath}`); + reporter?.emit({ + artifacts: manifest.artifacts, + command: "record", + event: "artifacts_written", + manifestPath, + sessionDir, + }); + reporter?.log("ffmpeg was not found on PATH. Generated plan only."); + reporter?.log(`Source video: ${renderSourcePath}`); for (const plan of ffmpegPlans) { - console.log(`Planned ${plan.format} output: ${plan.outputPath}`); - console.log(`Run manually: ffmpeg ${plan.args.join(" ")}`); + reporter?.log(`Planned ${plan.format} output: ${plan.outputPath}`); + reporter?.log(`Run manually: ffmpeg ${plan.args.join(" ")}`); } + + reporter?.emit({ + command: "record", + durationMs: Date.now() - startedAt, + event: "command_completed", + manifestPath, + sessionDir, + status: "partial", + warning: "ffmpeg_missing", + }); + + return { + captureUrl, + manifest, + manifestPath, + recordingPath, + sessionDir, + }; } diff --git a/src/reporter.ts b/src/reporter.ts new file mode 100644 index 0000000..ee3020a --- /dev/null +++ b/src/reporter.ts @@ -0,0 +1,39 @@ +type ReporterEventName = + | "artifacts_written" + | "browser_started" + | "capture_completed" + | "capture_started" + | "command_completed" + | "command_failed" + | "command_started" + | "render_completed" + | "render_started" + | "service_started"; + +export type ReporterEvent = { + event: ReporterEventName; + [key: string]: unknown; +}; + +export type Reporter = { + emit: (event: ReporterEvent) => void; + log: (message: string) => void; +}; + +export function createHumanReporter(): Reporter { + return { + emit: () => {}, + log: (message) => { + console.log(message); + }, + }; +} + +export function createJsonReporter(): Reporter { + return { + emit: (event) => { + process.stdout.write(`${JSON.stringify(event)}\n`); + }, + log: () => {}, + }; +} diff --git a/src/run.ts b/src/run.ts index 5fea54a..27c5d7b 100644 --- a/src/run.ts +++ b/src/run.ts @@ -6,8 +6,10 @@ import { chromium } from "playwright"; import { DemoCameraController } from "./camera-controller.js"; import { prepareComposition } from "./composition.js"; +import type { LoadConfigOverrides } from "./config.js"; import { loadConfig, loadDemoModule } from "./config.js"; import { DemoCursorController } from "./cursor-controller.js"; +import { ScreenstageError } from "./errors.js"; import { installCursorOverlay, moveCursorOverlay } from "./cursor-overlay.js"; import { buildFfmpegPlans, @@ -18,9 +20,14 @@ import { renderWithFfmpeg, } from "./ffmpeg.js"; import { writeMarkerArtifacts } from "./markers.js"; +import type { Reporter } from "./reporter.js"; import { runScenes } from "./scenes.js"; -import { startManagedService } from "./serve.js"; -import { artifact, writeSessionManifest } from "./session-manifest.js"; +import { startManagedService, type ManagedService } from "./serve.js"; +import { + artifact, + writeSessionManifest, + type SessionManifest, +} from "./session-manifest.js"; import { applyContextSetup, applyPageEmulation, @@ -29,28 +36,86 @@ import { } from "./setup.js"; type StorageState = Awaited>; +type BrowserInstance = Awaited>; + +export type RunMotionOptions = { + configOverrides?: LoadConfigOverrides; + reporter?: Reporter; +}; + +export type RunMotionResult = { + captureUrl: string; + manifest: SessionManifest; + manifestPath: string; + sessionDir: string; +}; function stamp(): string { return new Date().toISOString().replaceAll(":", "-"); } -export async function runMotion(configPath: string): Promise { - const config = await loadConfig(configPath); +export async function runMotion( + configPath: string, + options: RunMotionOptions = {}, +): Promise { + const reporter = options.reporter; + const startedAt = Date.now(); + const config = await loadConfig(configPath, options.configOverrides); const demoModule = await loadDemoModule(config.demoPath); const captureUrl = resolveCaptureUrl(config); const sessionName = `${config.name}-${stamp()}`; const sessionDir = path.join(config.output.dir, sessionName); const recordingsDir = path.join(sessionDir, "recordings"); const sourceVideoPath = path.join(sessionDir, "source.webm"); - const managedService = await startManagedService(config); + let managedService: ManagedService | undefined; + let manifest: SessionManifest | undefined; + let manifestPath: string | undefined; + + reporter?.emit({ + command: "run", + configPath: config.configPath, + event: "command_started", + outputDir: config.output.dir, + sessionDir, + }); + + managedService = await startManagedService(config); + + if (managedService) { + reporter?.emit({ + command: "run", + configPath: config.configPath, + event: "service_started", + targetUrl: captureUrl, + }); + } await fs.mkdir(recordingsDir, { recursive: true }); const compositionLayout = await prepareComposition(sessionDir, config); - const browser = await chromium.launch({ - channel: config.browser.channel, + let browser: BrowserInstance; + 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, }); let storageState: StorageState | undefined; @@ -128,6 +193,13 @@ export async function runMotion(configPath: string): Promise { | undefined; try { + reporter?.emit({ + captureUrl, + command: "run", + event: "capture_started", + viewport: config.viewport, + }); + if (config.browser.cursor.mode === "motion") { await installCursorOverlay(page, { hideSelectors: config.browser.cursor.hideSelectors, @@ -164,10 +236,18 @@ export async function runMotion(configPath: string): Promise { const recordedVideoPath = await video?.path(); if (!recordedVideoPath) { - throw new Error("Playwright did not produce a source video."); + throw new ScreenstageError( + "CAPTURE_FAILURE", + "Playwright did not produce a source video.", + ); } await fs.rename(recordedVideoPath, sourceVideoPath); + reporter?.emit({ + command: "run", + event: "capture_completed", + sourceVideoPath, + }); const ffmpegPlans = buildFfmpegPlans( sourceVideoPath, @@ -186,7 +266,6 @@ export async function runMotion(configPath: string): Promise { config.output.fps, editMarkers, ); - let manifestPath: string | undefined; await fs.writeFile( path.join(sessionDir, "timeline.json"), @@ -211,9 +290,29 @@ export async function runMotion(configPath: string): Promise { ); if (await commandExists("ffmpeg")) { + reporter?.emit({ + command: "run", + event: "render_started", + plannedOutputs: ffmpegPlans.map((plan) => ({ + format: plan.format, + outputPath: plan.outputPath, + })), + }); + for (const plan of ffmpegPlans) { - await renderWithFfmpeg(plan); - console.log(`Rendered ${plan.format} video: ${plan.outputPath}`); + try { + await renderWithFfmpeg(plan); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + format: plan.format, + outputPath: plan.outputPath, + }, + ); + } + reporter?.log(`Rendered ${plan.format} video: ${plan.outputPath}`); } const reviewSourcePath = @@ -228,18 +327,51 @@ export async function runMotion(configPath: string): Promise { posterPath = path.join(sessionDir, "poster.png"); contactSheetPath = path.join(sessionDir, "contact-sheet.png"); - await renderPosterFrame(reviewSourcePath, posterPath, durationSeconds); - console.log(`Rendered poster frame: ${posterPath}`); - - await renderContactSheet(reviewSourcePath, contactSheetPath, durationSeconds); - console.log(`Rendered contact sheet: ${contactSheetPath}`); - - markerStillPaths = await renderMarkerStills( - reviewSourcePath, - path.join(sessionDir, "markers"), - editMarkers, - durationSeconds, - ); + try { + await renderPosterFrame(reviewSourcePath, posterPath, durationSeconds); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + artifact: "poster", + outputPath: posterPath, + }, + ); + } + reporter?.log(`Rendered poster frame: ${posterPath}`); + + try { + await renderContactSheet(reviewSourcePath, contactSheetPath, durationSeconds); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + artifact: "contact-sheet", + outputPath: contactSheetPath, + }, + ); + } + reporter?.log(`Rendered contact sheet: ${contactSheetPath}`); + + try { + markerStillPaths = await renderMarkerStills( + reviewSourcePath, + path.join(sessionDir, "markers"), + editMarkers, + durationSeconds, + ); + } catch (error) { + throw new ScreenstageError( + "RENDER_FAILURE", + error instanceof Error ? error.message : String(error), + { + artifact: "marker-stills", + outputDir: path.join(sessionDir, "markers"), + }, + ); + } } manifestPath = await writeSessionManifest({ @@ -273,6 +405,7 @@ export async function runMotion(configPath: string): Promise { mode: "run", sessionDir, }); + manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as SessionManifest; await fs.writeFile( path.join(sessionDir, "timeline.json"), @@ -296,11 +429,36 @@ export async function runMotion(configPath: string): Promise { "utf8", ); - if (manifestPath) { - console.log(`Wrote session manifest: ${manifestPath}`); - } + reporter?.emit({ + artifacts: manifest.artifacts, + command: "run", + event: "artifacts_written", + manifestPath, + sessionDir, + }); + reporter?.emit({ + command: "run", + durationMs: Date.now() - startedAt, + event: "render_completed", + manifestPath, + }); + reporter?.log(`Wrote session manifest: ${manifestPath}`); - return; + const result = { + captureUrl, + manifest, + manifestPath, + sessionDir, + }; + reporter?.emit({ + command: "run", + durationMs: Date.now() - startedAt, + event: "command_completed", + manifestPath, + sessionDir, + status: "success", + }); + return result; } manifestPath = await writeSessionManifest({ @@ -320,6 +478,7 @@ export async function runMotion(configPath: string): Promise { mode: "run", sessionDir, }); + manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as SessionManifest; await fs.writeFile( path.join(sessionDir, "timeline.json"), @@ -343,11 +502,35 @@ export async function runMotion(configPath: string): Promise { "utf8", ); - console.log("ffmpeg was not found on PATH. Generated plan only."); - console.log(`Source video: ${sourceVideoPath}`); + reporter?.emit({ + artifacts: manifest.artifacts, + command: "run", + event: "artifacts_written", + manifestPath, + sessionDir, + }); + reporter?.log("ffmpeg was not found on PATH. Generated plan only."); + reporter?.log(`Source video: ${sourceVideoPath}`); for (const plan of ffmpegPlans) { - console.log(`Planned ${plan.format} output: ${plan.outputPath}`); - console.log(`Run manually: ffmpeg ${plan.args.join(" ")}`); + reporter?.log(`Planned ${plan.format} output: ${plan.outputPath}`); + reporter?.log(`Run manually: ffmpeg ${plan.args.join(" ")}`); } + + reporter?.emit({ + command: "run", + durationMs: Date.now() - startedAt, + event: "command_completed", + manifestPath, + sessionDir, + status: "partial", + warning: "ffmpeg_missing", + }); + + return { + captureUrl, + manifest, + manifestPath, + sessionDir, + }; } diff --git a/src/serve.ts b/src/serve.ts index ddfd8d7..ea541bd 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -7,7 +7,7 @@ import type { LoadedMotionConfig } from "./types.js"; const execFileAsync = promisify(execFile); -type ManagedService = { +export type ManagedService = { stop: () => Promise; }; diff --git a/src/session-manifest.ts b/src/session-manifest.ts index 598ea6f..d5dcdf3 100644 --- a/src/session-manifest.ts +++ b/src/session-manifest.ts @@ -4,27 +4,74 @@ import path from "node:path"; import type { EditMarker } from "./markers.js"; import type { LoadedMotionConfig } from "./types.js"; -type ManifestArtifact = { +export type ManifestArtifact = { path: string; type: string; }; -type ManifestArtifacts = { +export type ManifestArtifacts = { [key: string]: ManifestArtifact | ManifestArtifact[] | undefined; }; +export type SessionManifestMode = "manual-record" | "run"; + +export type SessionManifest = { + artifacts: ManifestArtifacts; + camera: { + mode: LoadedMotionConfig["camera"]["mode"]; + preset: LoadedMotionConfig["camera"]["preset"]; + zoom: number; + }; + captureUrl: string; + composition: { + device: LoadedMotionConfig["composition"]["device"]; + preset: LoadedMotionConfig["composition"]["preset"]; + }; + config: { + fps: number; + name: string; + outputPreset: LoadedMotionConfig["output"]["preset"]; + viewport: LoadedMotionConfig["viewport"]; + }; + createdAt: string; + durationSeconds?: number; + markerCount: number; + markers: Array<{ + label: string; + source: EditMarker["source"]; + timeMs: number; + type: EditMarker["type"]; + }>; + mode: SessionManifestMode; + schemaVersion: 1; + session: { + dir: string; + name: string; + }; +}; + type SessionManifestInput = { artifacts: ManifestArtifacts; captureUrl: string; config: LoadedMotionConfig; durationSeconds?: number; markers: EditMarker[]; - mode: "manual-record" | "run"; + mode: SessionManifestMode; sessionDir: string; }; function relativeArtifactPath(sessionDir: string, filePath: string): string { - return path.relative(sessionDir, filePath) || "."; + const relativePath = path.relative(sessionDir, filePath) || "."; + + if ( + relativePath === "." || + relativePath.startsWith(`..${path.sep}`) || + relativePath === ".." + ) { + return path.resolve(filePath); + } + + return relativePath; } function artifact( @@ -48,46 +95,44 @@ export async function writeSessionManifest({ sessionDir, }: SessionManifestInput): Promise { const manifestPath = path.join(sessionDir, "manifest.json"); + const manifest: SessionManifest = { + artifacts, + camera: { + mode: config.camera.mode, + preset: config.camera.preset, + zoom: config.camera.zoom, + }, + captureUrl, + composition: { + device: config.composition.device, + preset: config.composition.preset, + }, + config: { + fps: config.output.fps, + name: config.name, + outputPreset: config.output.preset, + viewport: config.viewport, + }, + createdAt: new Date().toISOString(), + durationSeconds, + markerCount: markers.length, + markers: markers.map((marker) => ({ + label: marker.label, + source: marker.source, + timeMs: marker.timeMs, + type: marker.type, + })), + mode, + schemaVersion: 1, + session: { + dir: sessionDir, + name: path.basename(sessionDir), + }, + }; await fs.writeFile( manifestPath, - JSON.stringify( - { - artifacts, - camera: { - mode: config.camera.mode, - preset: config.camera.preset, - zoom: config.camera.zoom, - }, - captureUrl, - composition: { - device: config.composition.device, - preset: config.composition.preset, - }, - config: { - fps: config.output.fps, - name: config.name, - outputPreset: config.output.preset, - viewport: config.viewport, - }, - createdAt: new Date().toISOString(), - durationSeconds, - markerCount: markers.length, - markers: markers.map((marker) => ({ - label: marker.label, - source: marker.source, - timeMs: marker.timeMs, - type: marker.type, - })), - mode, - session: { - dir: sessionDir, - name: path.basename(sessionDir), - }, - }, - null, - 2, - ), + JSON.stringify(manifest, null, 2), "utf8", );