Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 78 additions & 4 deletions .docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ A project is a folder containing `backstage.json` plus `scenes/` and (optionally
```json
{
"record": { "monitor": "eDP-1", "fps": 30, "out": "recordings" },
"popup": { "size": [1200, 560], "cps": 32 },
"popup": { "size": [1200, 560], "cps": 32,
"style": { "fontSize": 20, "chrome": "minimal" }
},
"term": "ghostty",
"env": { "APP_HOME": "${PROJECT}/.state" },
"hooks": { "setup": "hooks/setup.sh", "reset": "hooks/reset.sh" },
Expand All @@ -30,15 +32,54 @@ A project is a folder containing `backstage.json` plus `scenes/` and (optionally
| `record.out` | output dir, relative to the project | `recordings` |
| `popup.size` | `[width, height]` of the instruction box | `[1200, 560]` |
| `popup.cps` | typing speed of the box (chars/sec) | `32` |
| `popup.style.fontSize` | terminal font size for the built-in Prompter | `18` |
| `popup.style.title` | popup terminal window title | `instruction.md` |
| `popup.style.header` | text shown in the Prompter header | `instruction.md` |
| `popup.style.chrome` | header treatment: `default`, `minimal`, or `none` | `default` |
| `popup.style.class` | Hyprland window class for popup rules/closing | `backstage.popup` |
| `term` | terminal command used for the stage and popup | `ghostty` |
| `env` | map exported to panes, hooks, recorder, popup | — |
| `env` | map exported to panes, hooks, props, and offline transitions | — |
| `hooks.setup` | script run when a scene is `"fresh"` | — |
| `hooks.reset` | script run before every other take | — |
| `aliases` | custom action names → `{action, target}` | — |
| `layouts` | named stage layouts (see below) | — |

The video is written to `<project>/<record.out>/<scene-name>.mp4`.

## Popup style

The built-in Prompter is intentionally small: a Hyprland floating terminal that
types short narration. Use `popup.style` for basic project branding:

```jsonc
"popup": {
"size": [1280, 420],
"cps": 60,
"style": {
"fontSize": 22,
"title": "backstage.prompt",
"header": "backstage@demo:~$",
"chrome": "minimal",
"class": "backstage.demo.popup"
}
}
```

`chrome` controls only the header:

| Value | Effect |
|-------|--------|
| `default` | dim framed header, matching the original `instruction.md` look |
| `minimal` | plain header text |
| `none` | no header; only typed text |

Complex HTML/CSS animation, multiple boxes, fullscreen chapter cards, or
transparent overlays belong in **live transitions** (below), not in the built-in
Prompter.

The current popup driver targets Hyprland and the configured terminal. macOS,
Windows, and non-Hyprland popup backends are out of scope for this driver.

## Trust boundary

`backstage.json` and `scenes/*.json` are executable project configuration: pane
Expand Down Expand Up @@ -108,6 +149,7 @@ between them into one video. Three pieces in `backstage.json`:

"transitions": {
"to-deploy": { "cmd": "node slide.js --title Deploy --out {{out}} --size {{w}}x{{h}}" },
"to-browser": { "live": { "prop": "transitions/browser-card.sh", "args": ["--title", "Browser"] } },
"to-guards": { "cmd": "node slide.js --title Guards --out {{out}} --size {{w}}x{{h}}" }
},

Expand Down Expand Up @@ -136,8 +178,10 @@ The target geometry every clip is normalized to before concatenation.

### transitions

A transition is **a full command you write** — any tool, any language. Backstage
substitutes placeholders and then expects a clip:
Transitions have two render modes.

An **offline transition** is a full command you write. Backstage substitutes
placeholders and expects the command to write an mp4 to `{{out}}`:

| Placeholder | Becomes |
|-------------|---------|
Expand All @@ -150,6 +194,36 @@ script called with different `--title`). The command runs with the project `env`
and the project root as its working dir, and must leave a non-empty mp4 at
`{{out}}` (Backstage normalizes it to the render geometry/fps).

Placeholder values are inserted verbatim into this trusted project shell command.
Quote placeholders in `backstage.json` when you need shell word boundaries or
literal handling, for example `--title '{{from}}'`.

A **live transition** runs a blocking project-relative prop while Backstage records
the screen. The prop owns its visual lifecycle: open the overlay/window, wait for
animation, close it, then exit.

```jsonc
"transitions": {
"chapter-browser": {
"live": {
"prop": "transitions/chapter.sh",
"args": ["--title", "Browser", "--duration", "2.2"]
}
}
}
```

When a production reaches a live transition, Backstage records that prop as its
own transition segment and stitches it between scene clips. If both `live` and
`cmd` are present, `live` takes precedence (the shared render-mode rule used by
productions, in-scene steps, and validation); `cmd` remains a fallback-compatible
offline definition for projects that choose it.

A live prop's `args` support the same placeholders as an offline `cmd`
(`{{w}}`, `{{h}}`, `{{fps}}`, `{{from}}`, `{{to}}`) **except `{{out}}`**: the
recorder owns the clip file, so `{{out}}` is substituted to an empty string for
live props — never hand a live prop the recording path.

### productions

| Field | Meaning |
Expand Down
2 changes: 2 additions & 0 deletions .docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ scenes/NN.json + backstage.json (project)
│ type → tmux send-keys (literal text) in the target pane
│ keys → tmux send-keys (named keys) in the target pane
│ prop → run an external script (any tool), blocking
│ transition → run a configured live transition prop as an overlay
│ wait → just pause
└─► stop recording ──► <project>/<out>/NN.mp4
Expand All @@ -33,6 +34,7 @@ scenes/NN.json + backstage.json (project)
| `internal/stage` | builds the stage (tmux + Hyprland fullscreen) |
| `internal/recorder` | start/stop the screen recorder |
| `internal/prompter` | floating box + typewriter (char-by-char) |
| `internal/transition` | offline transition rendering + placeholder substitution |
| `internal/pane` | target a pane by name (tmux send-keys) + keymap |

Nothing in the core names a specific tool — tool specifics live in `projects/`.
Expand Down
29 changes: 27 additions & 2 deletions .docs/scenes.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ For the project config the scene runs under, see [Configuration](configuration.m
| `type` | `target`, `value` | type `value` literally (no Enter) in the target pane |
| `keys` | `target`, `commands` | send named keys / literals to the target pane |
| `prop` | `value`, `args` | run an external script (any tool: browser, RPA, setup) |
| `transition` | `value`, `args` | run a configured live transition as an in-scene overlay |
| `wait` | — | just pause |

Optional on any step: `delay-before`, `delay-after` (seconds), `key-delay`
Expand All @@ -53,12 +54,36 @@ the first pane.
{"action": "prop", "value": "props/click.py", "args": ["--btn", "ok"]}
```

The path is taken as-is if absolute, otherwise relative to the project root. The
script runs with the project `env` and the project root as its working dir,
The path must be relative to the project root; absolute paths and project escapes
are rejected. The script runs with the project `env` and the project root as its
working dir,
blocks until it exits, and a non-zero exit is reported. This is how a scene
reaches beyond the terminal: drive a browser, run an e2e suite, automate a
desktop app. Whatever it puts on screen is recorded.

### transition: reuse a live transition inside a scene

```json
{"action": "transition", "value": "chapter-browser", "args": ["--subtitle", "Anything visible can be recorded"]}
```

`value` names a transition from `backstage.json`. The transition must define
`live.prop`; offline-only `cmd` transitions are valid for productions, but cannot
run inside a scene. Backstage runs the live prop from the project root, appends
the step `args` after the transition's configured `live.args`, substitutes the
in-scene placeholders `{{w}}`/`{{h}}`/`{{fps}}`, blocks until the prop exits, and
records whatever it showed on screen as part of the current scene. Use this for
HTML/CSS chapter cards, animated overlays, or other visuals that need more
control than the short built-in Prompter.

`{{from}}`, `{{to}}`, and `{{out}}` are **not** available in-scene. `{{from}}`/
`{{to}}` are production-only: they name the surrounding scenes of a transition
segment, and an in-scene step has no neighbouring scenes, so they would only ever
substitute to empty here. `{{out}}` is omitted because the recorder owns the clip
file. (As in a production segment, `{{fps}}` falls back to `record.fps`, and when
`render.w`/`render.h` are unset `{{w}}`/`{{h}}` substitute to `0`, meaning "monitor
native" — the prop must treat `0` as native.)

### Aliases

A scene can use friendly action names that the project config maps to a canonical
Expand Down
22 changes: 21 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,32 @@ func killCmd() *cobra.Command {
Use: "kill",
Short: "Tear down the stage and dismiss any popup",
RunE: func(_ *cobra.Command, _ []string) error {
_ = (&prompter.Hypr{}).Close()
// Resolve the configured popup class tolerantly: even if the rest of
// the config no longer validates, a custom-class popup must still be
// dismissible. Fall back to the default class only if no config is found.
class := prompter.DefaultClass
if cfgPath, err := findConfigFrom(projectFlag); err == nil {
class = scene.PopupClassFor(cfgPath)
}
_ = (&prompter.Hypr{}).CloseClass(class)
return (&stage.Hypr{}).Teardown()
},
}
}

// findConfigFrom locates the project config from an explicit dir or by searching
// up from the current directory, without loading/validating it.
func findConfigFrom(dir string) (string, error) {
if dir == "" {
var err error
if dir, err = os.Getwd(); err != nil {
return "", err
}
}
cfgPath, _, err := scene.FindConfig(filepath.Join(dir, "_"))
return cfgPath, err
}

// runScene loads the scene + its project, validates, and runs it.
func runScene(scenePath string, opts engine.Options) error {
cfgPath, _, err := scene.FindConfig(scenePath)
Expand Down
79 changes: 64 additions & 15 deletions internal/engine/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ package engine

import (
"fmt"
"os"
"os/exec"
"time"

"github.com/This-Is-NPC/backstage/internal/prompter"
"github.com/This-Is-NPC/backstage/internal/scene"
"github.com/This-Is-NPC/backstage/internal/transition"
)

// runStep resolves aliases, honors delay-before/after, and dispatches the action.
Expand All @@ -20,6 +19,9 @@ func (e *Engine) runStep(i int, st scene.Step) error {
}

var err error
if (action == "run" || action == "type" || action == "keys") && e.pane == nil {
return fmt.Errorf("action %q needs a staged pane", action)
}
switch action {
case "dialog":
err = e.actDialog(st)
Expand All @@ -31,6 +33,8 @@ func (e *Engine) runStep(i int, st scene.Step) error {
err = e.actKeys(target, st)
case "prop":
err = e.actProp(st)
case "transition":
err = e.actTransition(st)
case "wait":
// pause only
default:
Expand Down Expand Up @@ -60,23 +64,43 @@ func (e *Engine) resolve(st scene.Step) (action, target string) {
// actDialog shows the floating instruction box, holds while it types, then closes.
func (e *Engine) actDialog(st scene.Step) error {
cps := e.Project.Popup.CPS
opts := prompter.Opts{CPS: cps, Term: e.Project.Term}
if e.rehearsing {
// Rehearsals are host-independent dry runs; keep timing without opening Hypr.
if err := e.sleepDialog(st, cps); err != nil {
return err
}
e.sleep(dialogPost)
return nil
}
style := e.Project.Popup.Style
opts := prompter.Opts{
CPS: cps, Term: e.Project.Term,
FontSize: style.FontSize, Title: style.Title, Header: style.Header,
Chrome: style.Chrome, Class: style.Class,
}
if len(e.Project.Popup.Size) == 2 {
opts.Width, opts.Height = e.Project.Popup.Size[0], e.Project.Popup.Size[1]
}
if err := e.Prompt.Show(st.Value, opts); err != nil {
return err
}
if err := e.sleepDialog(st, cps); err != nil {
return err
}
if err := e.Prompt.Close(); err != nil {
return err
}
e.sleep(dialogPost)
return nil
}

func (e *Engine) sleepDialog(st scene.Step, cps int) error {
hold := st.Hold
if hold == 0 {
hold = defHold
}
typeSecs := prompter.TypeDuration(st.Value, float64(cps)).Seconds()
e.sleep(typeSecs + hold)
if err := e.Prompt.Close(); err != nil {
return err
}
e.sleep(dialogPost)
return nil
}

Expand All @@ -95,17 +119,42 @@ func (e *Engine) actProp(st scene.Step) error {
if st.Value == "" {
return nil
}
path, err := e.Project.SafePath(st.Value)
return e.runProp("prop "+st.Value, st.Value, st.Args)
}

func (e *Engine) actTransition(st scene.Step) error {
if st.Value == "" {
return fmt.Errorf("transition action needs value")
}
t, ok := e.Project.Transitions[st.Value]
if !ok {
return fmt.Errorf("transition %q not in config", st.Value)
}
if t.RenderMode() != scene.RenderLive {
return fmt.Errorf("transition %q has no live.prop", st.Value)
}
// Substitute placeholders via the shared resolver so an in-scene step and a
// production segment apply the SAME config fallbacks. {{out}} is intentionally
// omitted for live props (the recorder owns the clip). {{fps}} falls back to
// record.fps. {{w}}/{{h}} are the configured render dims; when render.w/h are
// unset they substitute to "0" ("monitor native") rather than concrete pixels:
// unlike production, an in-scene step does not record a probe clip, so the true
// native size is not knowable here. The prop must treat 0 as "native". (This is
// the only deliberate divergence from production, which probes for real pixels.)
fps, w, h := e.Project.ResolveRenderDims()
v := transition.Vars{W: w, H: h, FPS: fps}
args := transition.SubstituteLiveArgs(t.Live.Args, v)
args = append(args, transition.SubstituteLiveArgs(st.Args, v)...)
return e.runProp("transition "+st.Value, t.Live.Prop, args)
}

func (e *Engine) runProp(label, rel string, args []string) error {
cmd, err := e.Project.PropCommand(rel, args)
if err != nil {
return err
}
cmd := exec.Command(path, st.Args...)
cmd.Dir = e.Project.Dir
cmd.Env = e.env()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("prop %s: %w", st.Value, err)
if err := e.runCommand(cmd); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
return nil
}
Loading
Loading