diff --git a/.docs/configuration.md b/.docs/configuration.md index f813a2f..e5d5e54 100644 --- a/.docs/configuration.md +++ b/.docs/configuration.md @@ -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" }, @@ -30,8 +32,13 @@ 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}` | — | @@ -39,6 +46,40 @@ A project is a folder containing `backstage.json` plus `scenes/` and (optionally The video is written to `//.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 @@ -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}}" } }, @@ -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 | |-------------|---------| @@ -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 | diff --git a/.docs/how-it-works.md b/.docs/how-it-works.md index 73995ae..37f5289 100644 --- a/.docs/how-it-works.md +++ b/.docs/how-it-works.md @@ -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 ──► //NN.mp4 @@ -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/`. diff --git a/.docs/scenes.md b/.docs/scenes.md index fa01a4c..90e6be9 100644 --- a/.docs/scenes.md +++ b/.docs/scenes.md @@ -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` @@ -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 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f8b96a3..80a7d20 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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) diff --git a/internal/engine/actions.go b/internal/engine/actions.go index 655f219..0cc3a06 100644 --- a/internal/engine/actions.go +++ b/internal/engine/actions.go @@ -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. @@ -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) @@ -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: @@ -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 } @@ -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 } diff --git a/internal/engine/command_guard.go b/internal/engine/command_guard.go new file mode 100644 index 0000000..56291a9 --- /dev/null +++ b/internal/engine/command_guard.go @@ -0,0 +1,60 @@ +package engine + +import ( + "errors" + "os/exec" + "sync" + + "github.com/This-Is-NPC/backstage/internal/scene" +) + +var ErrCommandInterrupted = errors.New("command start canceled by interrupt") + +// CommandGuard gates command starts against interrupt cleanup. If Interrupt races +// with Start, Interrupt waits until Start has either failed or produced a process +// handle before killing the command's process group. +type CommandGuard struct { + mu sync.Mutex + interrupted bool + active *exec.Cmd +} + +func (g *CommandGuard) Start(cmd *exec.Cmd) error { + if g == nil { + return cmd.Start() + } + g.mu.Lock() + defer g.mu.Unlock() + if g.interrupted { + return ErrCommandInterrupted + } + g.active = cmd + if err := cmd.Start(); err != nil { + if g.active == cmd { + g.active = nil + } + return err + } + return nil +} + +func (g *CommandGuard) Done(cmd *exec.Cmd) { + if g == nil { + return + } + g.mu.Lock() + defer g.mu.Unlock() + if g.active == cmd { + g.active = nil + } +} + +func (g *CommandGuard) Interrupt() { + if g == nil { + return + } + g.mu.Lock() + defer g.mu.Unlock() + g.interrupted = true + _ = scene.KillProcessGroup(g.active) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 90dda1d..607f676 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -5,9 +5,7 @@ import ( "fmt" "os" "os/exec" - "os/signal" "sync" - "syscall" "github.com/This-Is-NPC/backstage/internal/pane" "github.com/This-Is-NPC/backstage/internal/prompter" @@ -29,6 +27,9 @@ type Options struct { // montage appears in the video. Default (false) starts after the stage is // ready, hiding the setup. ShowStaging bool + // OnInterrupt runs after recorder/popup/stage cleanup but before the interrupt + // handler exits. Production uses this to remove its segment work directory. + OnInterrupt func() } // Engine runs a scene over the stage/recorder/prompter/pane drivers. @@ -38,8 +39,10 @@ type Engine struct { Rec recorder.Recorder Prompt prompter.Prompter - Speed float64 - pane pane.Driver + Speed float64 + pane pane.Driver + rehearsing bool + cmdGuard *CommandGuard } // New builds an Engine with the default Hyprland/gpu drivers for a project. @@ -60,80 +63,116 @@ func (e *Engine) Run(s *scene.Scene, opts Options) (runErr error) { if e.Speed <= 0 { e.Speed = 1 } + prevRehearsing := e.rehearsing + e.rehearsing = !opts.Record + defer func() { e.rehearsing = prevRehearsing }() layout, ok := e.Project.Layouts[s.LayoutName()] if !ok { return fmt.Errorf("layout %q not in config", s.LayoutName()) } - if err := e.runHooks(s); err != nil { - return err - } - - out := opts.OutPath - if out == "" { - if err := scene.ValidateName("scene", s.Name); err != nil { - return err - } - var err error - out, err = e.Project.SafePath(e.Project.Record.Out, s.Name+".mp4") - if err != nil { - return err + // Preflight the prompter/host before any recording. Any run that WILL record a + // Hypr-driven overlay must fail fast, not finalize an overlay-less video. The + // readiness check matches what the scene actually needs: + // - a dialog step opens the prompter terminal → needs hyprctl AND the terminal + // (full Preflight); + // - a live transition step records a prop over the compositor overlay but never + // opens the terminal → needs hyprctl only (PreflightHypr), so it must not + // fail on a host missing the terminal. + // Only fail-fast when actually recording: a rehearse/dry-run (Record==false) + // produces no video, so it must not require hyprctl/terminal on a non-Hypr host. + if opts.Record && e.Prompt != nil { + switch { + case e.hasDialogStep(s): + if err := e.Prompt.Preflight(e.Project.Term); err != nil { + return err + } + case e.recordsHyprOverlay(s): + if err := e.Prompt.PreflightHypr(); err != nil { + return err + } } } + var recMu sync.Mutex - recStarted := false + recArmed := false recStopped := false stopRec := func() error { recMu.Lock() - defer recMu.Unlock() - if !opts.Record || !recStarted || recStopped { + if !opts.Record || !recArmed || recStopped { + recMu.Unlock() return nil } recStopped = true + recMu.Unlock() fmt.Println(">> stop recording") _, err := e.Rec.Stop() return err } - sigCh := make(chan os.Signal, 1) - done := make(chan struct{}) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + // Cancel active command starts, stop the recorder, and tear down overlays on + // interrupt, then exit. + // Shared with production.recordLiveTransition via InterruptGuard so the + // command-cancel + recorder-stop-once + signal-exit pattern can't drift between + // the two sites. + // (recArmed/recStopped stay under recMu here because startRec sets them + // concurrently; the guard provides the signal handling and exit.) + // + // The guard owns command cancellation and recorder Stop before running this + // onInterrupt, so the closure must not touch the recorder or the guard itself -- + // it only does overlay and caller cleanup. Not referencing the outer guard also + // removes the construction-window nil-deref the closure used to risk. + prevCmdGuard := e.cmdGuard + cmdGuard := &CommandGuard{} + e.cmdGuard = cmdGuard + defer func() { e.cmdGuard = prevCmdGuard }() + + guard := newInterruptGuard( + func() error { + cmdGuard.Interrupt() + return stopRec() + }, + func() { e.cleanupOnInterrupt(opts.OnInterrupt) }, + ) defer func() { - signal.Stop(sigCh) - close(done) - if err := stopRec(); err != nil { + if err := guard.Stop(); err != nil { runErr = errors.Join(runErr, err) } + guard.Release() }() - go func() { - select { - case <-sigCh: - _ = stopRec() - if e.Prompt != nil { - _ = e.Prompt.Close() - } - if e.Stager != nil { - _ = e.Stager.Teardown() - } - os.Exit(130) - case <-done: + + if err := e.runHooks(s); err != nil { + return err + } + + out := opts.OutPath + if out == "" { + if err := scene.ValidateName("scene", s.Name); err != nil { + return err } - }() + var err error + out, err = e.Project.SafePath(e.Project.Record.Out, s.Name+".mp4") + if err != nil { + return err + } + } startRec := func() error { if !opts.Record { return nil } + recMu.Lock() + recArmed = true + recStopped = false + recMu.Unlock() fmt.Println(">> start recording") if err := e.Rec.Start(out); err != nil { return err } - recMu.Lock() - recStarted = true - recStopped = false - recMu.Unlock() return nil } + usesStage := len(layout.Panes) > 0 + // ShowStaging: capture the stage montage too (record before staging). if opts.ShowStaging { if err := startRec(); err != nil { @@ -141,13 +180,18 @@ func (e *Engine) Run(s *scene.Scene, opts Options) (runErr error) { } } - fmt.Printf(">> stage layout: %s\n", s.LayoutName()) - m, err := e.Stager.Setup(layout, e.Project) - if err != nil { - return err + if usesStage { + fmt.Printf(">> stage layout: %s\n", s.LayoutName()) + m, err := e.Stager.Setup(layout, e.Project) + if err != nil { + return err + } + e.pane = pane.NewTmux(m) + e.sleep(stageWarm) + } else { + fmt.Printf(">> stage layout: %s (none)\n", s.LayoutName()) + e.pane = nil } - e.pane = pane.NewTmux(m) - e.sleep(stageWarm) // Default: start after the stage is ready, hiding the setup. if !opts.ShowStaging { @@ -156,6 +200,9 @@ func (e *Engine) Run(s *scene.Scene, opts Options) (runErr error) { } } + // Continue-on-error is intentional: for a live recorder a partial take beats + // a discarded one, so a failed step is logged and joined into runErr (surfaced + // to the caller) rather than aborting the remaining steps. for i, st := range s.Steps { if err := e.runStep(i, st); err != nil { fmt.Fprintf(os.Stderr, " !! step %d: %v\n", i+1, err) @@ -175,6 +222,60 @@ func (e *Engine) Run(s *scene.Scene, opts Options) (runErr error) { return runErr } +func (e *Engine) cleanupOnInterrupt(extra func()) { + // Primary recorder/command cleanup is owned by the guard; repeat the command + // interrupt here so direct cleanupOnInterrupt tests and future callers stay safe. + e.interruptActiveCommand() + if e.Prompt != nil { + _ = e.Prompt.Close() + } + if e.Stager != nil { + _ = e.Stager.Teardown() + } + if extra != nil { + extra() + } +} + +func (e *Engine) interruptActiveCommand() { + if e.cmdGuard != nil { + e.cmdGuard.Interrupt() + } +} + +// hasDialogStep reports whether the scene has a dialog step, which opens the +// prompter terminal and so requires the full hyprctl+terminal preflight. Aliases +// are resolved so an aliased dialog step is still detected. +func (e *Engine) hasDialogStep(s *scene.Scene) bool { + for _, st := range s.Steps { + if action, _ := e.resolve(st); action == "dialog" { + return true + } + } + return false +} + +// recordsHyprOverlay reports whether the scene will record any Hypr-driven +// overlay — a dialog step (popup) or a live transition step (recorded prop) — +// so the host-readiness preflight runs before recording for every such path. +// Aliases are resolved so an aliased dialog/transition step is still detected. +func (e *Engine) recordsHyprOverlay(s *scene.Scene) bool { + for _, st := range s.Steps { + action, _ := e.resolve(st) + switch action { + case "dialog": + return true + case "transition": + // Only a live transition records a Hypr overlay; an offline-cmd + // transition used as a step is rejected by validation, but guard here. + if t, ok := e.Project.Transitions[st.Value]; ok && t.RenderMode() == scene.RenderLive { + return true + } + } + } + return false +} + // runHooks runs the setup hook for a fresh scene, else the reset hook. func (e *Engine) runHooks(s *scene.Scene) error { h := e.Project.Hooks @@ -198,18 +299,46 @@ func (e *Engine) runScript(rel string) error { } cmd := exec.Command(path) cmd.Dir = e.Project.Dir - cmd.Env = e.env() - if err := cmd.Run(); err != nil { - return fmt.Errorf("hook %s: %w", rel, err) + cmd.Env = e.Project.PropEnv() + scene.SetProcessGroup(cmd) + var runErr error + if e.cmdGuard != nil { + runErr = e.runCommand(cmd) + } else { + runErr = runInterruptibleCommand(cmd) + } + if runErr != nil { + return fmt.Errorf("hook %s: %w", rel, runErr) } return nil } -// env is the process environment plus the project's exported env block. -func (e *Engine) env() []string { - env := os.Environ() - for k, v := range e.Project.Env { - env = append(env, k+"="+v) +func (e *Engine) runCommand(cmd *exec.Cmd) error { + g := e.cmdGuard + if g == nil { + if err := cmd.Start(); err != nil { + return err + } + return cmd.Wait() + } + if err := g.Start(cmd); err != nil { + return err + } + defer g.Done(cmd) + return cmd.Wait() +} + +func runInterruptibleCommand(cmd *exec.Cmd) error { + cmdGuard := &CommandGuard{} + guard := newInterruptGuard(func() error { + cmdGuard.Interrupt() + return nil + }, nil) + defer guard.Release() + + if err := cmdGuard.Start(cmd); err != nil { + return err } - return env + defer cmdGuard.Done(cmd) + return cmd.Wait() } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 07f5089..9887462 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -1,8 +1,11 @@ package engine import ( + "errors" "os" + "os/exec" "path/filepath" + "strings" "testing" "time" @@ -26,16 +29,51 @@ func (f *fakePane) Keys(t string, c []string, _ time.Duration) error { } type fakePrompt struct { - shown []string - closed int + shown []string + opts []prompter.Opts + closed int + preflights []string + hyprPreflights int + preErr error + hyprPreErr error } -func (f *fakePrompt) Show(text string, _ prompter.Opts) error { +func (f *fakePrompt) Preflight(term string) error { + f.preflights = append(f.preflights, term) + return f.preErr +} +func (f *fakePrompt) PreflightHypr() error { + f.hyprPreflights++ + return f.hyprPreErr +} +func (f *fakePrompt) Show(text string, opts prompter.Opts) error { f.shown = append(f.shown, text) + f.opts = append(f.opts, opts) return nil } func (f *fakePrompt) Close() error { f.closed++; return nil } +type fakeEngineGuard struct { + stop func() error + onInterrupt func() +} + +func (f *fakeEngineGuard) Stop() error { + if f.stop == nil { + return nil + } + return f.stop() +} + +func (f *fakeEngineGuard) Release() {} + +func (f *fakeEngineGuard) Interrupt() { + _ = f.Stop() + if f.onInterrupt != nil { + f.onInterrupt() + } +} + func newTestEngine(p *scene.Project) (*Engine, *fakePane, *fakePrompt) { fp := &fakePane{} pr := &fakePrompt{} @@ -44,6 +82,34 @@ func newTestEngine(p *scene.Project) (*Engine, *fakePane, *fakePrompt) { return e, fp, pr } +func TestDialogPassesPopupStyle(t *testing.T) { + e, _, pr := newTestEngine(&scene.Project{ + Term: "ghostty", + Popup: scene.PopupCfg{ + CPS: 1000, + Size: []int{900, 300}, + Style: scene.PopupStyleCfg{ + FontSize: 24, + Title: "backstage.prompt", + Header: "backstage@demo:~$", + Chrome: "minimal", + Class: "backstage.demo.popup", + }, + }, + }) + if err := e.runStep(0, scene.Step{Action: "dialog", Value: "styled"}); err != nil { + t.Fatalf("dialog: %v", err) + } + if len(pr.opts) != 1 { + t.Fatalf("expected one prompter opts, got %d", len(pr.opts)) + } + got := pr.opts[0] + if got.Width != 900 || got.Height != 300 || got.FontSize != 24 || got.Title != "backstage.prompt" || + got.Header != "backstage@demo:~$" || got.Chrome != "minimal" || got.Class != "backstage.demo.popup" { + t.Errorf("prompter opts not propagated: %+v", got) + } +} + // --- tests ------------------------------------------------------------------- func TestResolveAlias(t *testing.T) { @@ -112,6 +178,220 @@ func TestActProp(t *testing.T) { } } +func TestRunScriptStartsHookInProcessGroup(t *testing.T) { + dir := t.TempDir() + hook := filepath.Join(dir, "hook.sh") + if err := os.WriteFile(hook, []byte(`#!/bin/sh +read stat < /proc/$$/stat +set -- $stat +[ "$5" = "$$" ] || exit 7 +printf '%s' "$BACKSTAGE_TEST_ENV" > hook.out +`), 0o755); err != nil { + t.Fatal(err) + } + e := &Engine{Project: &scene.Project{Dir: dir, Env: map[string]string{"BACKSTAGE_TEST_ENV": "yes"}}} + if err := e.runScript("hook.sh"); err != nil { + t.Fatalf("runScript: %v", err) + } + if b, err := os.ReadFile(filepath.Join(dir, "hook.out")); err != nil || string(b) != "yes" { + t.Fatalf("hook env output = %q, %v", b, err) + } +} + +func TestRunInterruptibleCommandSkipsStartAfterInterrupt(t *testing.T) { + dir := t.TempDir() + started := filepath.Join(dir, "started") + cmd := exec.Command("sh", "-c", `printf started > "$1"`, "sh", started) + scene.SetProcessGroup(cmd) + + oldGuard := newInterruptGuard + newInterruptGuard = func(stop func() error, onInterrupt func()) interruptGuard { + if stop != nil { + _ = stop() + } + if onInterrupt != nil { + onInterrupt() + } + return &fakeEngineGuard{} + } + defer func() { newInterruptGuard = oldGuard }() + + err := runInterruptibleCommand(cmd) + if !errors.Is(err, ErrCommandInterrupted) { + t.Fatalf("runInterruptibleCommand error = %v, want %v", err, ErrCommandInterrupted) + } + if _, err := os.Stat(started); !os.IsNotExist(err) { + t.Fatalf("command started after interrupt cleanup; stat err=%v", err) + } +} + +func TestRunInterruptDuringHookKillsHookAndRunsOptionCleanup(t *testing.T) { + dir := t.TempDir() + ticks := filepath.Join(dir, "ticks") + ready := filepath.Join(dir, "ready") + cleaned := filepath.Join(dir, "cleaned") + hook := filepath.Join(dir, "hook.sh") + if err := os.WriteFile(hook, []byte(`#!/bin/sh +(while :; do printf x >> "$TICKS"; sleep 0.05; done) & +printf ready > "$READY" +wait +`), 0o755); err != nil { + t.Fatal(err) + } + + guards := make(chan *fakeEngineGuard, 2) + oldGuard := newInterruptGuard + newInterruptGuard = func(stop func() error, onInterrupt func()) interruptGuard { + g := &fakeEngineGuard{stop: stop, onInterrupt: onInterrupt} + guards <- g + return g + } + defer func() { newInterruptGuard = oldGuard }() + + e := &Engine{ + Project: &scene.Project{ + Dir: dir, + Env: map[string]string{"TICKS": ticks, "READY": ready}, + Hooks: scene.Hooks{Setup: "hook.sh"}, + Layouts: map[string]scene.Layout{ + "screen": {Panes: []scene.Pane{}}, + }, + }, + Prompt: &fakePrompt{}, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "screen", Fresh: true} + errCh := make(chan error, 1) + go func() { + errCh <- e.Run(s, Options{Record: false, Speed: 0.0001, OnInterrupt: func() { + _ = os.WriteFile(cleaned, []byte("yes"), 0o644) + }}) + }() + + var guard *fakeEngineGuard + select { + case guard = <-guards: + case <-time.After(2 * time.Second): + t.Fatal("interrupt guard was not installed before hooks") + } + waitForTestFile(t, ready) + guard.Interrupt() + + select { + case err := <-errCh: + if err == nil { + t.Fatal("killed hook should report an error") + } + case <-time.After(2 * time.Second): + t.Fatal("Engine.Run did not return after interrupt cleanup killed the hook") + } + if len(guards) != 0 { + t.Fatalf("hook installed a competing interrupt guard") + } + if b, err := os.ReadFile(cleaned); err != nil || string(b) != "yes" { + t.Fatalf("OnInterrupt cleanup output = %q, %v", b, err) + } + before, _ := os.ReadFile(ticks) + time.Sleep(200 * time.Millisecond) + after, _ := os.ReadFile(ticks) + if len(after) != len(before) { + t.Fatalf("hook child process kept running after process-group kill: before=%d after=%d", len(before), len(after)) + } +} + +func TestActTransition(t *testing.T) { + dir := t.TempDir() + log := filepath.Join(dir, "args.log") + script := filepath.Join(dir, "live.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\n' \"$@\" > args.log\n"), 0o755); err != nil { + t.Fatal(err) + } + e := &Engine{Project: &scene.Project{Dir: dir, Transitions: map[string]scene.Transition{ + "chapter": {Live: scene.LiveTransition{Prop: "live.sh", Args: []string{"--base"}}}, + }}, Speed: 1} + if err := e.actTransition(scene.Step{Action: "transition", Value: "chapter", Args: []string{"--extra"}}); err != nil { + t.Fatalf("transition should succeed: %v", err) + } + b, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + if got, want := string(b), "--base\n--extra\n"; got != want { + t.Errorf("transition args = %q, want %q", got, want) + } + if err := e.actTransition(scene.Step{Action: "transition", Value: "missing"}); err == nil { + t.Error("missing transition should fail") + } + e.Project.Transitions["offline"] = scene.Transition{Cmd: "render --out {{out}}"} + if err := e.actTransition(scene.Step{Action: "transition", Value: "offline"}); err == nil { + t.Error("offline transition used as step should fail") + } +} + +func TestActTransitionSubstitutesPlaceholders(t *testing.T) { + dir := t.TempDir() + log := filepath.Join(dir, "args.log") + script := filepath.Join(dir, "live.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > args.log\n"), 0o755); err != nil { + t.Fatal(err) + } + e := &Engine{Project: &scene.Project{ + Dir: dir, + Render: scene.RenderCfg{W: 1920, H: 1080, FPS: 60}, + Transitions: map[string]scene.Transition{ + // {{out}} must be substituted to empty (recorder owns the clip); + // {{w}}/{{h}}/{{fps}} come from the render config. + "chapter": {Live: scene.LiveTransition{Prop: "live.sh", Args: []string{ + "--out", "{{out}}", "--size", "{{w}}x{{h}}", "--fps", "{{fps}}", + }}}, + }, + }, Speed: 1} + if err := e.actTransition(scene.Step{Action: "transition", Value: "chapter"}); err != nil { + t.Fatalf("transition should succeed: %v", err) + } + b, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + want := "--out\n\n--size\n1920x1080\n--fps\n60\n" + if got := string(b); got != want { + t.Errorf("transition args = %q, want %q", got, want) + } +} + +// When render.* is omitted (the common config), the in-scene live transition must +// resolve {{fps}} via the record.fps fallback (matching the production pipeline's +// intent), and substitute {{w}}/{{h}} to the native sentinel "0" rather than a +// stale render.fps of 0. Asserts the shared ResolveRenderDims behavior. +func TestActTransitionFallsBackToRecordFPS(t *testing.T) { + dir := t.TempDir() + log := filepath.Join(dir, "args.log") + script := filepath.Join(dir, "live.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > args.log\n"), 0o755); err != nil { + t.Fatal(err) + } + e := &Engine{Project: &scene.Project{ + Dir: dir, + Record: scene.RecordCfg{FPS: 24}, // render.* omitted → fps falls back to 24 + Transitions: map[string]scene.Transition{ + "chapter": {Live: scene.LiveTransition{Prop: "live.sh", Args: []string{ + "--size", "{{w}}x{{h}}", "--fps", "{{fps}}", + }}}, + }, + }, Speed: 1} + if err := e.actTransition(scene.Step{Action: "transition", Value: "chapter"}); err != nil { + t.Fatalf("transition should succeed: %v", err) + } + b, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + want := "--size\n0x0\n--fps\n24\n" + if got := string(b); got != want { + t.Errorf("transition args = %q, want %q (fps must fall back to record.fps)", got, want) + } +} + func TestSpeedScaling(t *testing.T) { // a tiny factor collapses a 10s sleep to ~1ms e := &Engine{Speed: 0.0001} @@ -133,7 +413,12 @@ func (f *fakeStager) Setup(_ scene.Layout, _ *scene.Project) (*scene.Manifest, e *f.order = append(*f.order, "stage") return f.m, nil } -func (f *fakeStager) Teardown() error { return nil } +func (f *fakeStager) Teardown() error { + if f.order != nil { + *f.order = append(*f.order, "teardown") + } + return nil +} type fakeRec struct { order *[]string @@ -147,6 +432,17 @@ func (f *fakeRec) Start(out string) error { } func (f *fakeRec) Stop() (string, error) { return f.out, nil } +type startErrRec struct { + startErr error + stops int +} + +func (f *startErrRec) Start(string) error { return f.startErr } +func (f *startErrRec) Stop() (string, error) { + f.stops++ + return "", nil +} + func runClip(t *testing.T, opts Options) (order []string, recOut string) { t.Helper() if opts.Speed == 0 { @@ -189,6 +485,287 @@ func TestShowStagingRecordsFirst(t *testing.T) { } } +func TestEmptyPaneLayoutSkipsStaging(t *testing.T) { + var ord []string + rec := &fakeRec{order: &ord} + e := &Engine{ + Project: &scene.Project{ + Dir: t.TempDir(), + Record: scene.RecordCfg{Out: "recordings"}, + Layouts: map[string]scene.Layout{"screen": {Panes: []scene.Pane{}}}, + }, + Stager: &fakeStager{order: &ord, m: &scene.Manifest{}}, + Rec: rec, + Prompt: &fakePrompt{}, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "screen", Steps: []scene.Step{{Action: "wait"}}} + if err := e.Run(s, Options{Record: true, OutPath: "/tmp/clip.mp4", Speed: 0.0001}); err != nil { + t.Fatalf("Run: %v", err) + } + if len(ord) != 1 || ord[0] != "rec" { + t.Errorf("empty-pane layout should skip stage and only record, got %v", ord) + } +} + +func TestRunStopsRecorderWhenStartFailsAfterArming(t *testing.T) { + rec := &startErrRec{startErr: errors.New("recorder failed after spawn")} + e := &Engine{ + Project: &scene.Project{ + Dir: t.TempDir(), + Record: scene.RecordCfg{Out: "recordings"}, + Layouts: map[string]scene.Layout{"screen": {Panes: []scene.Pane{}}}, + }, + Rec: rec, + Prompt: &fakePrompt{}, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "screen", Steps: []scene.Step{{Action: "wait"}}} + if err := e.Run(s, Options{Record: true, OutPath: filepath.Join(t.TempDir(), "clip.mp4"), Speed: 0.0001}); err == nil { + t.Fatal("Run should return the recorder start error") + } + if rec.stops != 1 { + t.Fatalf("recorder Stop called %d times, want 1", rec.stops) + } +} + +func TestCleanupOnInterruptRunsOptionCallback(t *testing.T) { + var order []string + pr := &fakePrompt{} + e := &Engine{Prompt: pr, Stager: &fakeStager{order: &order}} + e.cleanupOnInterrupt(func() { order = append(order, "extra") }) + if pr.closed != 1 { + t.Fatalf("Prompt.Close called %d times, want 1", pr.closed) + } + if got, want := strings.Join(order, ","), "teardown,extra"; got != want { + t.Fatalf("cleanup order = %q, want %q", got, want) + } +} + +func TestDialogPreflightFailsBeforeRecording(t *testing.T) { + var ord []string + rec := &fakeRec{order: &ord} + pr := &fakePrompt{preErr: errors.New("popup driver hypr-terminal requires hyprctl")} + e := &Engine{ + Project: &scene.Project{ + Dir: t.TempDir(), + Term: "ghostty", + Record: scene.RecordCfg{Out: "recordings"}, + Layouts: map[string]scene.Layout{"solo": {Panes: []scene.Pane{{Name: "t"}}}}, + }, + Stager: &fakeStager{order: &ord, m: &scene.Manifest{Panes: map[string]string{"t": "%1"}, Order: []string{"t"}}}, + Rec: rec, + Prompt: pr, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "solo", Steps: []scene.Step{{Action: "dialog", Value: "hello"}}} + err := e.Run(s, Options{Record: true, OutPath: "/tmp/clip.mp4", Speed: 0.0001}) + if err == nil { + t.Fatal("Run should fail fast when prompter preflight fails") + } + if len(pr.preflights) != 1 || pr.preflights[0] != "ghostty" { + t.Errorf("preflight should run once with configured term, got %v", pr.preflights) + } + if len(ord) != 0 { + t.Errorf("no recording or staging should occur before preflight failure, got %v", ord) + } + if len(pr.shown) != 0 { + t.Errorf("dialog should never be shown after preflight failure, got %v", pr.shown) + } +} + +func TestLiveTransitionStepPreflightsBeforeRecording(t *testing.T) { + // A transition-only scene records a compositor overlay but never opens the + // prompter terminal, so it must fail fast on a missing compositor (PreflightHypr) + // without requiring the terminal (full Preflight must not run). + var ord []string + rec := &fakeRec{order: &ord} + pr := &fakePrompt{hyprPreErr: errors.New("popup driver hypr-terminal requires hyprctl")} + e := &Engine{ + Project: &scene.Project{ + Dir: t.TempDir(), + Term: "ghostty", + Record: scene.RecordCfg{Out: "recordings"}, + Layouts: map[string]scene.Layout{"solo": {Panes: []scene.Pane{{Name: "t"}}}}, + Transitions: map[string]scene.Transition{ + "chapter": {Live: scene.LiveTransition{Prop: "live.sh"}}, + }, + }, + Stager: &fakeStager{order: &ord, m: &scene.Manifest{Panes: map[string]string{"t": "%1"}, Order: []string{"t"}}}, + Rec: rec, + Prompt: pr, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "solo", Steps: []scene.Step{ + {Action: "transition", Value: "chapter"}, + }} + err := e.Run(s, Options{Record: true, OutPath: "/tmp/clip.mp4", Speed: 0.0001}) + if err == nil { + t.Fatal("Run should fail fast when a live transition step's hyprctl preflight fails") + } + if pr.hyprPreflights != 1 { + t.Errorf("hyprctl-only preflight should run once for a live transition step, got %d", pr.hyprPreflights) + } + if len(pr.preflights) != 0 { + t.Errorf("a transition-only scene must not run the full (terminal) preflight, got %v", pr.preflights) + } + if len(ord) != 0 { + t.Errorf("no recording or staging should occur before preflight failure, got %v", ord) + } +} + +// A dialog scene opens the prompter terminal, so a missing terminal (surfaced via +// the full Preflight) must fail the run. +func TestDialogPreflightRequiresTerminal(t *testing.T) { + var ord []string + rec := &fakeRec{order: &ord} + pr := &fakePrompt{preErr: errors.New(`popup driver hypr-terminal requires terminal "ghostty"`)} + e := &Engine{ + Project: &scene.Project{ + Dir: t.TempDir(), + Term: "ghostty", + Record: scene.RecordCfg{Out: "recordings"}, + Layouts: map[string]scene.Layout{"solo": {Panes: []scene.Pane{{Name: "t"}}}}, + }, + Stager: &fakeStager{order: &ord, m: &scene.Manifest{Panes: map[string]string{"t": "%1"}, Order: []string{"t"}}}, + Rec: rec, + Prompt: pr, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "solo", Steps: []scene.Step{{Action: "dialog", Value: "hi"}}} + if err := e.Run(s, Options{Record: true, OutPath: "/tmp/clip.mp4", Speed: 0.0001}); err == nil { + t.Fatal("dialog scene missing the terminal should fail") + } + if len(pr.preflights) != 1 || pr.preflights[0] != "ghostty" { + t.Errorf("dialog scene should run the full preflight once, got %v", pr.preflights) + } + if pr.hyprPreflights != 0 { + t.Errorf("dialog scene should not run the hyprctl-only preflight, got %d", pr.hyprPreflights) + } +} + +// A transition-only scene missing the terminal (but with hyprctl present) must NOT +// fail on the terminal: it uses the compositor overlay, never the terminal. +func TestTransitionOnlySceneTolerantOfMissingTerminal(t *testing.T) { + var ord []string + rec := &fakeRec{order: &ord} + // preErr is set (terminal "missing") to prove the full Preflight is never used; + // hyprPreErr is nil so the hyprctl-only check passes. + pr := &fakePrompt{preErr: errors.New("terminal missing — must not be consulted")} + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "live.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + e := &Engine{ + Project: &scene.Project{ + Dir: dir, + Term: "ghostty", + Record: scene.RecordCfg{Out: "recordings"}, + Layouts: map[string]scene.Layout{"solo": {Panes: []scene.Pane{{Name: "t"}}}}, + Transitions: map[string]scene.Transition{ + "chapter": {Live: scene.LiveTransition{Prop: "live.sh"}}, + }, + }, + Stager: &fakeStager{order: &ord, m: &scene.Manifest{Panes: map[string]string{"t": "%1"}, Order: []string{"t"}}}, + Rec: rec, + Prompt: pr, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "solo", Steps: []scene.Step{{Action: "transition", Value: "chapter"}}} + if err := e.Run(s, Options{Record: true, OutPath: filepath.Join(dir, "clip.mp4"), Speed: 0.0001}); err != nil { + t.Fatalf("transition-only scene must not fail on a missing terminal: %v", err) + } + if pr.hyprPreflights != 1 { + t.Errorf("transition-only scene should run the hyprctl-only preflight once, got %d", pr.hyprPreflights) + } + if len(pr.preflights) != 0 { + t.Errorf("transition-only scene must not run the full (terminal) preflight, got %v", pr.preflights) + } +} + +func TestOfflineTransitionStepSkipsPreflight(t *testing.T) { + // An offline-only transition records no Hypr overlay, so no preflight runs. + // (Validation would reject it as a step, but Run must not preflight regardless.) + var ord []string + rec := &fakeRec{order: &ord} + pr := &fakePrompt{preErr: errors.New("should not be called")} + e := &Engine{ + Project: &scene.Project{ + Dir: t.TempDir(), + Record: scene.RecordCfg{Out: "recordings"}, + Layouts: map[string]scene.Layout{"solo": {Panes: []scene.Pane{{Name: "t"}}}}, + Transitions: map[string]scene.Transition{ + "fade": {Cmd: "render --out {{out}}"}, + }, + }, + Stager: &fakeStager{order: &ord, m: &scene.Manifest{Panes: map[string]string{"t": "%1"}, Order: []string{"t"}}}, + Rec: rec, + Prompt: pr, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "solo", Steps: []scene.Step{{Action: "wait"}}} + if err := e.Run(s, Options{Record: true, OutPath: "/tmp/clip.mp4", Speed: 0.0001}); err != nil { + t.Fatalf("Run: %v", err) + } + if len(pr.preflights) != 0 { + t.Errorf("offline transition (no overlay) should not preflight, got %v", pr.preflights) + } +} + +func TestRehearsalSkipsPreflight(t *testing.T) { + // A rehearse/dry-run (Record==false) produces no video, so a dialog scene must + // NOT preflight hyprctl/terminal — it must run fine on a non-Hypr host. + var ord []string + rec := &fakeRec{order: &ord} + pr := &fakePrompt{preErr: errors.New("popup driver hypr-terminal requires hyprctl")} + e := &Engine{ + Project: &scene.Project{ + Dir: t.TempDir(), + Term: "ghostty", + Record: scene.RecordCfg{Out: "recordings"}, + Layouts: map[string]scene.Layout{"solo": {Panes: []scene.Pane{{Name: "t"}}}}, + }, + Stager: &fakeStager{order: &ord, m: &scene.Manifest{Panes: map[string]string{"t": "%1"}, Order: []string{"t"}}}, + Rec: rec, + Prompt: pr, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "solo", Steps: []scene.Step{{Action: "dialog", Value: "hello"}}} + if err := e.Run(s, Options{Record: false, Speed: 0.0001}); err != nil { + t.Fatalf("rehearsal with a dialog step should not preflight: %v", err) + } + if len(pr.preflights) != 0 { + t.Errorf("rehearsal (Record=false) must not preflight, got %v", pr.preflights) + } + if len(pr.shown) != 0 { + t.Errorf("rehearsal (Record=false) must not open the Hypr popup, got %v", pr.shown) + } +} + +func TestNoDialogSkipsPreflight(t *testing.T) { + var ord []string + rec := &fakeRec{order: &ord} + pr := &fakePrompt{preErr: errors.New("should not be called")} + e := &Engine{ + Project: &scene.Project{ + Dir: t.TempDir(), + Record: scene.RecordCfg{Out: "recordings"}, + Layouts: map[string]scene.Layout{"solo": {Panes: []scene.Pane{{Name: "t"}}}}, + }, + Stager: &fakeStager{order: &ord, m: &scene.Manifest{Panes: map[string]string{"t": "%1"}, Order: []string{"t"}}}, + Rec: rec, + Prompt: pr, + Speed: 0.0001, + } + s := &scene.Scene{Name: "demo", Layout: "solo", Steps: []scene.Step{{Action: "wait"}}} + if err := e.Run(s, Options{Record: true, OutPath: "/tmp/clip.mp4", Speed: 0.0001}); err != nil { + t.Fatalf("Run with no dialog step should not preflight: %v", err) + } + if len(pr.preflights) != 0 { + t.Errorf("preflight should not run without a dialog step, got %v", pr.preflights) + } +} + func TestRunReturnsStepErrors(t *testing.T) { var ord []string e := &Engine{ @@ -205,3 +782,18 @@ func TestRunReturnsStepErrors(t *testing.T) { t.Fatal("Run should return a step error") } } + +func waitForTestFile(t *testing.T, path string) { + t.Helper() + deadline := time.After(2 * time.Second) + for { + if _, err := os.Stat(path); err == nil { + return + } + select { + case <-deadline: + t.Fatalf("timed out waiting for %s", path) + case <-time.After(10 * time.Millisecond): + } + } +} diff --git a/internal/engine/interrupt.go b/internal/engine/interrupt.go new file mode 100644 index 0000000..ca5c58b --- /dev/null +++ b/internal/engine/interrupt.go @@ -0,0 +1,150 @@ +package engine + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +const interruptExitCode = 130 + +var ( + interruptStopTimeout = 10 * time.Second + exitProcess = os.Exit +) + +type interruptGuard interface { + Stop() error + Release() +} + +var newInterruptGuard = func(stop func() error, onInterrupt func()) interruptGuard { + return NewInterruptGuard(stop, onInterrupt) +} + +// InterruptGuard centralizes the "run primary stop, then teardown and exit on +// SIGINT/SIGTERM" pattern shared by engine.Run and production.recordLiveTransition. +// Both paths must (a) start stop exactly once with no data race between the signal +// goroutine and the main return path, and (b) run their teardown before os.Exit so +// nothing leaks on interrupt. Keeping one implementation means the two sites can't +// drift. +// +// The interrupt-handler body is supplied by the caller (onInterrupt) so each site +// keeps its own secondary teardown. The guard owns stop and starts it before +// invoking onInterrupt; on the signal path it waits only a bounded time so +// secondary teardown still runs if primary shutdown hangs. onInterrupt must not +// call Stop itself and need not reference the guard at all. The handler runs once, +// then the process exits 130. +type InterruptGuard struct { + mu sync.Mutex + stopped bool + stop func() error // primary teardown; invoked at most once + stopDone chan struct{} + stopErr error + sigCh chan os.Signal + done chan struct{} + releaseOnce sync.Once +} + +// NewInterruptGuard installs a SIGINT/SIGTERM handler. On interrupt it first +// starts stop(), waits up to a bounded timeout, then runs onInterrupt (the +// caller's extra teardown), then exits with code 130. stop may be nil. Call +// Release on the normal path to deregister the handler. +func NewInterruptGuard(stop func() error, onInterrupt func()) *InterruptGuard { + g := &InterruptGuard{ + stop: stop, + sigCh: make(chan os.Signal, 1), + done: make(chan struct{}), + } + signal.Notify(g.sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + select { + case <-g.sigCh: + g.handleInterrupt(onInterrupt) + case <-g.done: + } + }() + return g +} + +func (g *InterruptGuard) handleInterrupt(onInterrupt func()) { + // Finalize the recorder before any caller teardown so the file is closed + // regardless of what onInterrupt does. Bound the interrupt path so secondary + // cleanup (popup/stage/temp files) still runs if recorder shutdown hangs. + _ = g.stopWithin(interruptStopTimeout) + if onInterrupt != nil { + onInterrupt() + } + exitProcess(interruptExitCode) +} + +// Stop runs the recorder teardown at most once, even across the signal goroutine +// and the main return path. Safe to call from both. +func (g *InterruptGuard) Stop() error { + done := g.startStop() + <-done + return g.stopError() +} + +func (g *InterruptGuard) stopWithin(timeout time.Duration) error { + done := g.startStop() + if timeout <= 0 { + <-done + return g.stopError() + } + t := time.NewTimer(timeout) + defer t.Stop() + select { + case <-done: + return g.stopError() + case <-t.C: + return fmt.Errorf("interrupt recorder stop timed out after %s", timeout) + } +} + +func (g *InterruptGuard) startStop() <-chan struct{} { + g.mu.Lock() + if g.stopDone != nil { + done := g.stopDone + g.mu.Unlock() + return done + } + done := make(chan struct{}) + g.stopDone = done + stop := g.stop + if g.stopped || stop == nil { + g.stopped = true + g.mu.Unlock() + close(done) + return done + } + g.stopped = true + g.mu.Unlock() + + go func() { + err := stop() + g.mu.Lock() + g.stopErr = err + close(done) + g.mu.Unlock() + }() + return done +} + +func (g *InterruptGuard) stopError() error { + g.mu.Lock() + defer g.mu.Unlock() + return g.stopErr +} + +// Release deregisters the signal handler and unblocks the goroutine. Call on the +// normal (non-interrupt) return path. Idempotent. +func (g *InterruptGuard) Release() { + g.releaseOnce.Do(func() { + signal.Stop(g.sigCh) + close(g.done) + }) +} diff --git a/internal/engine/interrupt_test.go b/internal/engine/interrupt_test.go new file mode 100644 index 0000000..3f1d741 --- /dev/null +++ b/internal/engine/interrupt_test.go @@ -0,0 +1,95 @@ +package engine + +import ( + "sync/atomic" + "testing" + "time" +) + +// On the normal (non-interrupt) return path, Release must deregister the handler +// and the goroutine must exit WITHOUT ever running onInterrupt. +func TestInterruptGuardReleaseDoesNotRunOnInterrupt(t *testing.T) { + var onInterruptRan atomic.Bool + g := NewInterruptGuard( + func() error { return nil }, + func() { onInterruptRan.Store(true) }, + ) + g.Release() + if err := g.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if onInterruptRan.Load() { + t.Fatal("onInterrupt ran on the normal path; it must only run on interrupt") + } +} + +// Stop must invoke the recorder teardown at most once, even when called from both +// the normal path and (conceptually) the signal goroutine. Release after Stop, or +// Stop after Release, must not double-call or panic. +func TestInterruptGuardStopRunsOnce(t *testing.T) { + var stops atomic.Int32 + g := NewInterruptGuard( + func() error { stops.Add(1); return nil }, + func() {}, + ) + defer g.Release() + + if err := g.Stop(); err != nil { + t.Fatalf("first Stop: %v", err) + } + if err := g.Stop(); err != nil { + t.Fatalf("second Stop: %v", err) + } + if got := stops.Load(); got != 1 { + t.Fatalf("recorder stop called %d times; want exactly 1", got) + } +} + +// A nil stop and nil onInterrupt must be safe to construct and tear down: this +// mirrors callers that arm the guard with no recorder teardown. +func TestInterruptGuardNilFuncs(t *testing.T) { + g := NewInterruptGuard(nil, nil) + g.Release() + if err := g.Stop(); err != nil { + t.Fatalf("Stop with nil stop: %v", err) + } +} + +func TestInterruptGuardInterruptCleanupIsBoundedWhenStopHangs(t *testing.T) { + oldTimeout := interruptStopTimeout + oldExit := exitProcess + interruptStopTimeout = 20 * time.Millisecond + var exitCode atomic.Int32 + events := make([]string, 0, 2) + exitProcess = func(code int) { + exitCode.Store(int32(code)) + events = append(events, "exit") + } + defer func() { + interruptStopTimeout = oldTimeout + exitProcess = oldExit + }() + + releaseStop := make(chan struct{}) + g := &InterruptGuard{stop: func() error { + <-releaseStop + return nil + }} + + start := time.Now() + g.handleInterrupt(func() { events = append(events, "cleanup") }) + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("interrupt cleanup waited too long for a hung stop: %s", elapsed) + } + if len(events) != 2 || events[0] != "cleanup" || events[1] != "exit" { + t.Fatalf("events = %v, want cleanup before exit", events) + } + if got := exitCode.Load(); got != interruptExitCode { + t.Fatalf("exit code = %d, want %d", got, interruptExitCode) + } + + close(releaseStop) + if err := g.Stop(); err != nil { + t.Fatalf("Stop after releasing hung stop: %v", err) + } +} diff --git a/internal/production/production.go b/internal/production/production.go index 6bd3026..86c372b 100644 --- a/internal/production/production.go +++ b/internal/production/production.go @@ -4,6 +4,7 @@ package production import ( + "errors" "fmt" "os" "os/exec" @@ -12,6 +13,8 @@ import ( "strings" "github.com/This-Is-NPC/backstage/internal/engine" + "github.com/This-Is-NPC/backstage/internal/prompter" + "github.com/This-Is-NPC/backstage/internal/recorder" "github.com/This-Is-NPC/backstage/internal/scene" "github.com/This-Is-NPC/backstage/internal/stage" "github.com/This-Is-NPC/backstage/internal/transition" @@ -34,6 +37,15 @@ type segment struct { from, to string // surrounding scenes (transitions only) } +type interruptGuard interface { + Stop() error + Release() +} + +var newInterruptGuard = func(stop func() error, onInterrupt func()) interruptGuard { + return engine.NewInterruptGuard(stop, onInterrupt) +} + // plan flattens a production into an ordered list of scene/transition segments. // A transition "after X" is placed between X and the next scene; a transition // with an empty "after" is an intro, placed before the first scene. @@ -88,10 +100,9 @@ func Run(opts Options) (string, error) { if speed <= 0 { speed = 1 } - fps := p.Render.FPS - if fps == 0 { - fps = p.Record.FPS - } + // fps/w/h share the in-scene resolver so the two paths can't drift; production + // then probes a recorded clip below to turn native (0) w/h into real pixels. + fps, _, _ := p.ResolveRenderDims() segDir, err := os.MkdirTemp("", "backstage-prod-*") if err != nil { @@ -102,8 +113,13 @@ func Run(opts Options) (string, error) { } else { fmt.Printf(">> segments: %s\n", segDir) } + cleanupSegmentsOnInterrupt := func() { + if !opts.KeepSegments { + _ = os.RemoveAll(segDir) + } + } - env := append(os.Environ(), projectEnv(p)...) + env := p.PropEnv() // 1. resolve target geometry. If config omitted either dimension, record the // first scene clip early so transitions can receive real {{w}}/{{h}} values. @@ -126,6 +142,7 @@ func Run(opts Options) (string, error) { fmt.Printf(">> scene %q → clip\n", sg.name) runErr := engine.New(p).Run(s, engine.Options{ Record: true, OutPath: clip, ShowStaging: opts.ShowStaging, Speed: speed, + OnInterrupt: cleanupSegmentsOnInterrupt, }) teardownErr := (&stage.Hypr{}).Teardown() if runErr != nil { @@ -184,11 +201,18 @@ func Run(opts Options) (string, error) { } } else { clip := filepath.Join(segDir, fmt.Sprintf("%03d-%s.mp4", i, sg.kind)) - fmt.Printf(">> transition %q (%s → %s) → clip\n", sg.name, sg.from, sg.to) t := p.Transitions[sg.name] v := transition.Vars{Out: clip, W: w, H: h, FPS: fps, From: sg.from, To: sg.to} - if err := transition.Render(t.Cmd, v, env, p.Dir); err != nil { - return "", err + if t.RenderMode() == scene.RenderLive { + fmt.Printf(">> live transition %q (%s → %s) → clip\n", sg.name, sg.from, sg.to) + if err := recordLiveTransition(p, t, v, clip, cleanupSegmentsOnInterrupt); err != nil { + return "", err + } + } else { + fmt.Printf(">> transition %q (%s → %s) → clip\n", sg.name, sg.from, sg.to) + if err := renderOfflineTransition(t.Cmd, v, env, p.Dir, cleanupSegmentsOnInterrupt); err != nil { + return "", err + } } raw[i] = clip } @@ -201,7 +225,7 @@ func Run(opts Options) (string, error) { return "", fmt.Errorf("segment %d produced no clip", i) } n := filepath.Join(segDir, fmt.Sprintf("n%03d.mp4", i)) - if err := normalize(c, n, w, h, fps); err != nil { + if err := normalize(c, n, w, h, fps, cleanupSegmentsOnInterrupt); err != nil { return "", err } norm = append(norm, n) @@ -218,18 +242,125 @@ func Run(opts Options) (string, error) { if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil { return "", err } - if err := concat(norm, out, segDir); err != nil { + if err := concatFinal(norm, out, segDir, cleanupSegmentsOnInterrupt); err != nil { return "", err } return out, nil } -func projectEnv(p *scene.Project) []string { - var env []string - for k, v := range p.Env { - env = append(env, k+"="+v) +func renderOfflineTransition(cmdText string, v transition.Vars, env []string, dir string, onInterrupt func()) error { + cmd := transition.BuildCommand(cmdText, v, env, dir) + if err := runProductionCommand(cmd, onInterrupt); err != nil { + return fmt.Errorf("transition command failed: %w", err) + } + return transition.VerifyOutput(v.Out) +} + +func runProductionCommand(cmd *exec.Cmd, onInterrupt func()) error { + scene.SetProcessGroup(cmd) + cmdGuard := &engine.CommandGuard{} + guard := newInterruptGuard(func() error { + cmdGuard.Interrupt() + return nil + }, func() { + if onInterrupt != nil { + onInterrupt() + } + }) + defer guard.Release() + + if err := cmdGuard.Start(cmd); err != nil { + return err + } + defer cmdGuard.Done(cmd) + return cmd.Wait() +} + +// recordLiveTransition records the screen while a live prop drives the overlay, +// then verifies a non-empty mp4 landed at clip. SIGINT/SIGTERM during the prop +// kills the prop, stops the recorder (exactly once), removes the temp clip, runs +// onInterrupt (cleans the production temp dir so it doesn't leak on Ctrl-C), and +// exits. The recorder-stop guard and signal handling are shared with engine.Run +// via engine.InterruptGuard so the two paths can't drift. +func recordLiveTransition(p *scene.Project, t scene.Transition, v transition.Vars, clip string, onInterrupt func()) error { + // Readiness preflight before recording: a missing hyprctl must fail fast, not + // finalize an overlay-less segment. A live transition segment records a prop + // over the compositor overlay and does NOT use the prompter terminal, so only + // hyprctl is required here (PreflightHypr), not the configured terminal. + if err := (&prompter.Hypr{}).PreflightHypr(); err != nil { + return err + } + // A live prop does NOT own the clip file; the recorder writes {{out}}. Omit + // {{out}} from the live substitution so the prop can't clobber the recording. + cmd, err := p.PropCommand(t.Live.Prop, transition.SubstituteLiveArgs(t.Live.Args, v)) + if err != nil { + return err + } + propGuard := &engine.CommandGuard{} + + rec := recorder.NewGPU(p.Record.Monitor, p.Record.FPS) + + // Arm the guard before starting the recorder, mirroring engine.Run. rec.Start + // blocks up to ~10s waiting for the output file; if no guard were installed + // during that wait, a SIGINT/SIGTERM would orphan the gpu-screen-recorder child + // (GPU encoder held, file written forever). The guard's stop path cancels future + // prop starts before stopping the recorder, so a signal during recorder warmup + // cannot let the prop start in the main goroutine before os.Exit runs. + // + // The guard owns prop cancellation and recorder shutdown before running + // onInterrupt, so onInterrupt only does temp teardown and never references the + // guard. Interrupt order: cancel/kill prop, stop recorder (bounded on signal), + // remove the clip, then onInterrupt (clean the production temp dir so it doesn't + // leak on Ctrl-C). + guard := newInterruptGuard( + func() error { + propGuard.Interrupt() + _, err := rec.Stop() + return err + }, + func() { + // Recorder cleanup is owned by the guard. + _ = os.Remove(clip) + if onInterrupt != nil { + onInterrupt() + } + }, + ) + defer guard.Release() + + if err := rec.Start(clip); err != nil { + // Guard armed but the prop never started. GPU.Start owns cleanup for any + // recorder child it spawned; Release only removes the signal handler. + guard.Release() + return err + } + + if err := propGuard.Start(cmd); err != nil { + stopErr := guard.Stop() + guard.Release() + return errors.Join(fmt.Errorf("live transition %s: %w", t.Live.Prop, err), stopErr) + } + + runErr := cmd.Wait() + propGuard.Done(cmd) + stopErr := guard.Stop() + guard.Release() + if runErr != nil { + return errors.Join(fmt.Errorf("live transition %s: %w", t.Live.Prop, runErr), stopErr) } - return env + if stopErr != nil { + return stopErr + } + // Verify a non-empty clip landed, mirroring the offline transition.Render + // invariant: a prop that exits 0 with no/short recording must not pass silently. + fi, err := os.Stat(clip) + if err != nil { + return fmt.Errorf("live transition %s wrote no output at %s: %w", t.Live.Prop, clip, err) + } + if fi.Size() == 0 { + return fmt.Errorf("live transition %s output is empty: %s", t.Live.Prop, clip) + } + return nil } // probeDims returns a video's width and height via ffprobe. @@ -259,21 +390,49 @@ func probeDims(path string) (int, int, error) { } // normalize re-encodes a clip to a fixed geometry/fps/pixfmt (audio dropped). -func normalize(in, out string, w, h, fps int) error { +func normalize(in, out string, w, h, fps int, onInterrupt func()) error { vf := fmt.Sprintf( "scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,fps=%d", w, h, w, h, fps) cmd := exec.Command("ffmpeg", "-y", "-i", in, "-vf", vf, "-pix_fmt", "yuv420p", "-c:v", "libx264", "-an", out) cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := runProductionCommand(cmd, onInterrupt); err != nil { return fmt.Errorf("normalize %s: %w", in, err) } return nil } +func concatFinal(clips []string, out, workDir string, onInterrupt func()) error { + outDir := filepath.Dir(out) + tmp, err := os.CreateTemp(outDir, ".production-*.mp4") + if err != nil { + return err + } + tmpOut := tmp.Name() + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpOut) + return err + } + cleanupConcatOnInterrupt := func() { + if onInterrupt != nil { + onInterrupt() + } + _ = os.Remove(tmpOut) + } + if err := concat(clips, tmpOut, workDir, cleanupConcatOnInterrupt); err != nil { + _ = os.Remove(tmpOut) + return err + } + if err := os.Rename(tmpOut, out); err != nil { + _ = os.Remove(tmpOut) + return err + } + return nil +} + // concat joins normalized clips (same codec/geometry) via the concat demuxer. -func concat(clips []string, out, workDir string) error { +func concat(clips []string, out, workDir string, onInterrupt func()) error { list := filepath.Join(workDir, "concat.txt") var b strings.Builder for _, c := range clips { @@ -289,7 +448,7 @@ func concat(clips []string, out, workDir string) error { cmd := exec.Command("ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", out) cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := runProductionCommand(cmd, onInterrupt); err != nil { return fmt.Errorf("concat: %w", err) } return nil diff --git a/internal/production/production_test.go b/internal/production/production_test.go index 8ac86b9..1fda6f9 100644 --- a/internal/production/production_test.go +++ b/internal/production/production_test.go @@ -1,9 +1,16 @@ package production import ( + "errors" + "os" + "os/exec" + "path/filepath" "testing" + "time" + "github.com/This-Is-NPC/backstage/internal/engine" "github.com/This-Is-NPC/backstage/internal/scene" + "github.com/This-Is-NPC/backstage/internal/transition" ) func TestPlanOrder(t *testing.T) { @@ -61,6 +68,18 @@ func TestAdHoc(t *testing.T) { } } +func TestTransitionRenderModeSelection(t *testing.T) { + offline := scene.Transition{Cmd: "render --out {{out}}"} + live := scene.Transition{Live: scene.LiveTransition{Prop: "live.sh"}} + both := scene.Transition{Cmd: "render --out {{out}}", Live: scene.LiveTransition{Prop: "live.sh"}} + if offline.RenderMode() == scene.RenderLive { + t.Error("offline-only transition should not record live") + } + if live.RenderMode() != scene.RenderLive || both.RenderMode() != scene.RenderLive { + t.Error("live transition should record live") + } +} + func TestPlanIntroTransition(t *testing.T) { prod := scene.Production{ Scenes: []string{"a", "b"}, @@ -88,3 +107,310 @@ func TestConcatEscape(t *testing.T) { t.Errorf("concatEscape = %q, want %q", got, want) } } + +func TestRecordLiveTransitionStopsGuardBeforeRelease(t *testing.T) { + stubDir := t.TempDir() + installStub(t, stubDir, "hyprctl", "#!/bin/sh\nexit 0\n") + installStub(t, stubDir, "gpu-screen-recorder", `#!/bin/sh +out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + out="$2" + shift 2 + else + shift + fi +done +printf warm > "$out" +trap 'printf final > "$out"; exit 0' INT +while true; do sleep 1; done +`) + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + projectDir := t.TempDir() + installStub(t, projectDir, "live.sh", "#!/bin/sh\nexit 0\n") + clip := filepath.Join(t.TempDir(), "clip.mp4") + p := &scene.Project{Dir: projectDir, Record: scene.RecordCfg{Monitor: "eDP-1", FPS: 30}} + + var events []string + oldGuard := newInterruptGuard + newInterruptGuard = func(stop func() error, _ func()) interruptGuard { + return &fakeProductionGuard{events: &events, stop: stop} + } + defer func() { newInterruptGuard = oldGuard }() + + err := recordLiveTransition(p, scene.Transition{Live: scene.LiveTransition{Prop: "live.sh"}}, transition.Vars{}, clip, nil) + if err != nil { + t.Fatalf("recordLiveTransition: %v", err) + } + if len(events) < 2 { + t.Fatalf("guard events = %v, want stop before release", events) + } + if events[0] != "stop" || events[1] != "release" { + t.Fatalf("guard events = %v, want stop before release", events) + } +} + +func TestRecordLiveTransitionSkipsPropStartAfterInterruptDuringRecorderWarmup(t *testing.T) { + stubDir := t.TempDir() + ready := filepath.Join(stubDir, "recorder-ready") + installStub(t, stubDir, "hyprctl", "#!/bin/sh\nexit 0\n") + installStub(t, stubDir, "gpu-screen-recorder", `#!/bin/sh +out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + out="$2" + shift 2 + else + shift + fi +done +printf ready > "$REC_READY" +trap 'printf final > "$out"; exit 0' INT TERM +while true; do sleep 1; done +`) + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("REC_READY", ready) + + projectDir := t.TempDir() + propStarted := filepath.Join(projectDir, "prop-started") + t.Setenv("PROP_STARTED", propStarted) + installStub(t, projectDir, "live.sh", `#!/bin/sh +printf started > "$PROP_STARTED" +exit 0 +`) + clip := filepath.Join(t.TempDir(), "clip.mp4") + p := &scene.Project{Dir: projectDir, Record: scene.RecordCfg{Monitor: "eDP-1", FPS: 30}} + + guards := make(chan *manualProductionGuard, 1) + oldGuard := newInterruptGuard + newInterruptGuard = func(stop func() error, onInterrupt func()) interruptGuard { + g := &manualProductionGuard{stop: stop, onInterrupt: onInterrupt} + guards <- g + return g + } + defer func() { newInterruptGuard = oldGuard }() + + errCh := make(chan error, 1) + go func() { + errCh <- recordLiveTransition(p, scene.Transition{Live: scene.LiveTransition{Prop: "live.sh"}}, transition.Vars{}, clip, nil) + }() + + var guard *manualProductionGuard + select { + case guard = <-guards: + case <-time.After(2 * time.Second): + t.Fatal("interrupt guard was not installed") + } + waitForFile(t, ready) + if err := guard.Stop(); err != nil { + t.Fatalf("interrupt stop: %v", err) + } + + select { + case err := <-errCh: + if !errors.Is(err, engine.ErrCommandInterrupted) { + t.Fatalf("recordLiveTransition error = %v, want %v", err, engine.ErrCommandInterrupted) + } + case <-time.After(2 * time.Second): + t.Fatal("recordLiveTransition did not return after interrupt canceled the prop start") + } + if _, err := os.Stat(propStarted); !os.IsNotExist(err) { + t.Fatalf("prop started after interrupt cleanup; stat err=%v", err) + } +} + +func TestRunProductionCommandInterruptKillsProcessGroupAndRunsCleanup(t *testing.T) { + dir := t.TempDir() + ticks := filepath.Join(dir, "ticks") + ready := filepath.Join(dir, "ready") + cleaned := filepath.Join(dir, "cleaned") + cmd := exec.Command("sh", "-c", ` +(while :; do printf x >> "$1"; sleep 0.05; done) & +printf ready > "$2" +wait +`, "sh", ticks, ready) + defer scene.KillProcessGroup(cmd) + + interrupts := make(chan func(), 1) + oldGuard := newInterruptGuard + newInterruptGuard = func(stop func() error, onInterrupt func()) interruptGuard { + interrupts <- func() { + if stop != nil { + _ = stop() + } + if onInterrupt != nil { + onInterrupt() + } + } + return &noopProductionGuard{} + } + defer func() { newInterruptGuard = oldGuard }() + + errCh := make(chan error, 1) + go func() { + errCh <- runProductionCommand(cmd, func() { + _ = os.WriteFile(cleaned, []byte("yes"), 0o644) + }) + }() + + var interrupt func() + select { + case interrupt = <-interrupts: + case <-time.After(2 * time.Second): + t.Fatal("interrupt guard was not installed") + } + waitForFile(t, ready) + interrupt() + + select { + case err := <-errCh: + if err == nil { + t.Fatal("killed command should report an error") + } + case <-time.After(2 * time.Second): + t.Fatal("command did not exit after interrupt cleanup") + } + if b, err := os.ReadFile(cleaned); err != nil || string(b) != "yes" { + t.Fatalf("cleanup output = %q, %v", b, err) + } + before, _ := os.ReadFile(ticks) + time.Sleep(200 * time.Millisecond) + after, _ := os.ReadFile(ticks) + if len(after) != len(before) { + t.Fatalf("child process kept running after process-group kill: before=%d after=%d", len(before), len(after)) + } +} + +func TestRunProductionCommandSkipsStartAfterInterrupt(t *testing.T) { + dir := t.TempDir() + started := filepath.Join(dir, "started") + cleaned := filepath.Join(dir, "cleaned") + cmd := exec.Command("sh", "-c", `printf started > "$1"`, "sh", started) + + oldGuard := newInterruptGuard + newInterruptGuard = func(stop func() error, onInterrupt func()) interruptGuard { + if stop != nil { + _ = stop() + } + if onInterrupt != nil { + onInterrupt() + } + return &noopProductionGuard{} + } + defer func() { newInterruptGuard = oldGuard }() + + err := runProductionCommand(cmd, func() { + _ = os.WriteFile(cleaned, []byte("yes"), 0o644) + }) + if !errors.Is(err, engine.ErrCommandInterrupted) { + t.Fatalf("runProductionCommand error = %v, want %v", err, engine.ErrCommandInterrupted) + } + if _, err := os.Stat(started); !os.IsNotExist(err) { + t.Fatalf("command started after interrupt cleanup; stat err=%v", err) + } + if b, err := os.ReadFile(cleaned); err != nil || string(b) != "yes" { + t.Fatalf("cleanup output = %q, %v", b, err) + } +} + +func TestConcatFinalInterruptPreservesExistingOutput(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "production.mp4") + if err := os.WriteFile(out, []byte("previous"), 0o644); err != nil { + t.Fatal(err) + } + clip := filepath.Join(dir, "clip.mp4") + if err := os.WriteFile(clip, []byte("clip"), 0o644); err != nil { + t.Fatal(err) + } + + oldGuard := newInterruptGuard + newInterruptGuard = func(stop func() error, onInterrupt func()) interruptGuard { + if stop != nil { + _ = stop() + } + if onInterrupt != nil { + onInterrupt() + } + return &noopProductionGuard{} + } + defer func() { newInterruptGuard = oldGuard }() + + err := concatFinal([]string{clip}, out, dir, nil) + if !errors.Is(err, engine.ErrCommandInterrupted) { + t.Fatalf("concatFinal error = %v, want %v", err, engine.ErrCommandInterrupted) + } + if b, err := os.ReadFile(out); err != nil || string(b) != "previous" { + t.Fatalf("existing output = %q, %v; want previous output preserved", b, err) + } + matches, err := filepath.Glob(filepath.Join(dir, ".production-*.mp4")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("temporary concat outputs left behind: %v", matches) + } +} + +type noopProductionGuard struct{} + +func (noopProductionGuard) Stop() error { return nil } +func (noopProductionGuard) Release() {} + +type manualProductionGuard struct { + stop func() error + onInterrupt func() +} + +func (m *manualProductionGuard) Stop() error { + if m.stop == nil { + return nil + } + return m.stop() +} + +func (m *manualProductionGuard) Release() {} + +func (m *manualProductionGuard) Interrupt() { + _ = m.Stop() + if m.onInterrupt != nil { + m.onInterrupt() + } +} + +type fakeProductionGuard struct { + events *[]string + stop func() error +} + +func (f *fakeProductionGuard) Stop() error { + *f.events = append(*f.events, "stop") + return f.stop() +} + +func (f *fakeProductionGuard) Release() { + *f.events = append(*f.events, "release") +} + +func installStub(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil { + t.Fatal(err) + } +} + +func waitForFile(t *testing.T, path string) { + t.Helper() + deadline := time.After(2 * time.Second) + for { + if _, err := os.Stat(path); err == nil { + return + } + select { + case <-deadline: + t.Fatalf("timed out waiting for %s", path) + case <-time.After(10 * time.Millisecond): + } + } +} diff --git a/internal/prompter/hypr_popup.go b/internal/prompter/hypr_popup.go index 72eb82b..8861bf9 100644 --- a/internal/prompter/hypr_popup.go +++ b/internal/prompter/hypr_popup.go @@ -1,29 +1,34 @@ package prompter import ( + "errors" "fmt" "os" "os/exec" + "regexp" "strconv" "strings" + "sync" + "syscall" "time" ) -// PopupClass is the window class the floating instruction box is matched by. -const PopupClass = "backstage.popup" - -// classRE is the Hyprland class match regex for the popup (dots escaped). -var classRE = "^(" + strings.ReplaceAll(PopupClass, ".", `\.`) + ")$" - // Hypr shows the popup as a floating, centered terminal window using omarchy's // windowrule mechanism, and types into it by re-executing the backstage binary // in __type mode. Ports popup.sh + typewriter.sh. type Hypr struct { // Self is the path to the backstage binary (defaults to os.Executable()). Self string - tmp string + + mu sync.Mutex + tmp string + class string + cmd *exec.Cmd + waitCh chan error } +var popupTerminateGrace = 2 * time.Second + func hyprctl(args ...string) error { return exec.Command("hyprctl", args...).Run() } @@ -38,36 +43,80 @@ func (h *Hypr) self() string { return "backstage" } -// Show writes text to a temp file, floats+centers+sizes the popup window, and -// spawns a terminal that types the text via `backstage __type`. -func (h *Hypr) Show(text string, opts Opts) error { - if h.tmp != "" { - _ = os.Remove(h.tmp) - h.tmp = "" +// PreflightHypr verifies only the compositor dependency (hyprctl) is on PATH. A +// scene that records a live transition overlay (but no dialog) drives the +// compositor for the overlay yet never opens the prompter terminal, so requiring +// the terminal here would wrongly fail such a scene on a host without it. +func (h *Hypr) PreflightHypr() error { + if _, err := exec.LookPath("hyprctl"); err != nil { + return fmt.Errorf("popup driver hypr-terminal requires hyprctl: %w", err) } - tmp, err := os.CreateTemp("", "backstage-popup-*.txt") - if err != nil { + return nil +} + +// Preflight verifies hyprctl and the configured terminal are on PATH so a scene +// with dialog steps fails fast before recording starts, rather than finalizing a +// narration-less video and surfacing the error afterwards. An empty term +// selects the driver default ("ghostty"). +func (h *Hypr) Preflight(term string) error { + if err := h.PreflightHypr(); err != nil { return err } - if _, err := tmp.WriteString(text); err != nil { - tmp.Close() - os.Remove(tmp.Name()) - return err + if term == "" { + term = DefaultTerm } - tmp.Close() + if _, err := exec.LookPath(term); err != nil { + return fmt.Errorf("popup driver hypr-terminal requires terminal %q: %w", term, err) + } + return nil +} +// Show writes text to a temp file, floats+centers+sizes the popup window, and +// spawns a terminal that types the text via `backstage __type`. +func (h *Hypr) Show(text string, opts Opts) error { + h.closeExisting() term := opts.Term if term == "" { - term = "ghostty" + term = DefaultTerm + } + if err := h.Preflight(term); err != nil { + return err } font := opts.FontSize if font == 0 { - font = 18 + font = DefaultFontSize + } + title := SafeTitle(opts.Title) + if title == "" { + title = DefaultTitle + } + header := opts.Header + if header == "" { + header = title + } + chrome := opts.Chrome + if chrome == "" { + chrome = DefaultChrome } + class := opts.Class + if class == "" { + class = DefaultClass + } + classRE := classREFor(class) cps := opts.CPS if cps == 0 { - cps = 32 + cps = DefaultCPS + } + tmp, err := os.CreateTemp("", "backstage-popup-*.txt") + if err != nil { + return err } + if _, err := tmp.WriteString(text); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + tmp.Close() // Clear any prior popup, then float/size/center the next window of this class. _ = hyprctl("dispatch", "closewindow", "class:"+classRE) @@ -76,24 +125,35 @@ func (h *Hypr) Show(text string, opts Opts) error { fmt.Sprintf("size %d %d, match:class %s", opts.Width, opts.Height, classRE)) _ = hyprctl("keyword", "windowrule", "center on, match:class "+classRE) - header := `printf '\n \033[2m── instruction.md ─────────────────────\033[0m\n\n'; ` - inner := header + + inner := headerCommand(header, chrome) + shquote(h.self()) + " __type " + shquote(tmp.Name()) + " " + strconv.Itoa(cps) + - "; read -r -t 600 _" + "; rm -f " + shquote(tmp.Name()) + "; read -r -t 600 _" cmd := exec.Command(term, - "--class="+PopupClass, - "--title=instruction.md", + "--class="+class, + "--title="+title, "--font-size="+strconv.Itoa(font), "-e", "bash", "-lc", inner) + setPopupProcessGroup(cmd) + waitCh := make(chan error, 1) + h.mu.Lock() if err := cmd.Start(); err != nil { + h.mu.Unlock() os.Remove(tmp.Name()) return fmt.Errorf("spawn popup terminal: %w", err) } + go func() { + waitCh <- cmd.Wait() + close(waitCh) + }() h.tmp = tmp.Name() + h.class = class + h.cmd = cmd + h.waitCh = waitCh + h.mu.Unlock() // Wait for it to map, then focus + recenter (center can miss over fullscreen). for i := 0; i < 20; i++ { - if h.mapped() { + if h.mapped(class) { break } time.Sleep(150 * time.Millisecond) @@ -105,22 +165,190 @@ func (h *Hypr) Show(text string, opts Opts) error { // Close dismisses the popup window. func (h *Hypr) Close() error { - err := hyprctl("dispatch", "closewindow", "class:"+classRE) - if h.tmp != "" { - if rmErr := os.Remove(h.tmp); err == nil && rmErr != nil && !os.IsNotExist(rmErr) { + h.mu.Lock() + class := h.class + h.mu.Unlock() + return h.CloseClass(class) +} + +// CloseClass dismisses a popup by class. Empty class falls back to the default. +func (h *Hypr) CloseClass(class string) error { + if class == "" { + class = DefaultClass + } + err := hyprctl("dispatch", "closewindow", "class:"+classREFor(class)) + tmp, cmd, waitCh := h.detachPopup(class) + if tmp != "" { + if rmErr := os.Remove(tmp); err == nil && rmErr != nil && !os.IsNotExist(rmErr) { err = rmErr } - h.tmp = "" + } + if procErr := closePopupProcess(cmd, waitCh); err == nil && procErr != nil { + err = procErr } return err } -func (h *Hypr) mapped() bool { +func (h *Hypr) closeExisting() { + h.mu.Lock() + hasPopup := h.tmp != "" || h.cmd != nil + class := h.class + h.mu.Unlock() + if hasPopup { + _ = h.CloseClass(class) + } +} + +func (h *Hypr) detachPopup(class string) (string, *exec.Cmd, chan error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.tmp == "" && h.cmd == nil { + return "", nil, nil + } + trackedClass := h.class + if trackedClass == "" { + trackedClass = DefaultClass + } + if trackedClass != class { + return "", nil, nil + } + tmp, cmd, waitCh := h.tmp, h.cmd, h.waitCh + h.tmp = "" + h.class = "" + h.cmd = nil + h.waitCh = nil + return tmp, cmd, waitCh +} + +func closePopupProcess(cmd *exec.Cmd, waitCh chan error) error { + if cmd == nil || cmd.Process == nil || waitCh == nil { + return nil + } + select { + case err := <-waitCh: + return expectedPopupExit(err) + default: + } + if err := signalPopupProcessGroup(cmd, syscall.SIGTERM); err != nil { + return fmt.Errorf("close popup terminal: %w", err) + } + select { + case err := <-waitCh: + return expectedPopupExit(err) + case <-time.After(popupTerminateGrace): + } + if err := signalPopupProcessGroup(cmd, syscall.SIGKILL); err != nil { + return fmt.Errorf("kill popup terminal: %w", err) + } + select { + case err := <-waitCh: + return expectedPopupExit(err) + case <-time.After(popupTerminateGrace): + return fmt.Errorf("popup terminal did not exit after SIGKILL") + } +} + +func setPopupProcessGroup(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +func signalPopupProcessGroup(cmd *exec.Cmd, sig syscall.Signal) error { + if cmd == nil || cmd.Process == nil { + return nil + } + pid := cmd.Process.Pid + if pid <= 0 { + return nil + } + if err := syscall.Kill(-pid, sig); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil + } + if sigErr := cmd.Process.Signal(sig); sigErr != nil && !errors.Is(sigErr, os.ErrProcessDone) { + return errors.Join(err, sigErr) + } + } + return nil +} + +func expectedPopupExit(err error) error { + if err == nil { + return nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { + if status.Signaled() { + switch status.Signal() { + case syscall.SIGTERM, syscall.SIGKILL, syscall.SIGHUP, syscall.SIGINT: + return nil + } + } + } + } + return err +} + +func (h *Hypr) mapped(class string) bool { out, err := exec.Command("hyprctl", "clients", "-j").Output() if err != nil { return false } - return strings.Contains(string(out), `"class": "`+PopupClass+`"`) + if class == "" { + class = DefaultClass + } + return strings.Contains(string(out), `"class": "`+class+`"`) +} + +func classREFor(class string) string { + if class == "" { + class = DefaultClass + } + return "^(" + regexp.QuoteMeta(class) + ")$" +} + +func headerCommand(header, chrome string) string { + header = literalHeader(header) + switch chrome { + case "none": + return "" + case "minimal": + return "printf '%s' " + shquote("\n "+header+"\n\n") + "; " + default: + return "printf '%s%s%s' " + + shquote("\n \033[2m── ") + " " + + shquote(header) + " " + + shquote(" ─────────────────────\033[0m\n\n") + "; " + } +} + +func literalHeader(header string) string { + var b strings.Builder + for _, r := range header { + switch r { + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + case 0x7f: + b.WriteString(`\x7f`) + default: + if r < 0x20 || (r >= 0x80 && r <= 0x9f) { + fmt.Fprintf(&b, `\x%02x`, r) + } else { + b.WriteRune(r) + } + } + } + return b.String() } // shquote single-quotes a string for safe embedding in a bash -lc command. diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 3686c0d..b76ef8e 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -1,5 +1,24 @@ package prompter +import ( + "fmt" + "strings" + "unicode/utf8" +) + +// Default popup-style values. These are the single source of truth for the +// built-in prompter's defaults; scene config defaulting (loader.applyDefaults) +// and the Hypr driver both reference these so the two can't drift. +const ( + DefaultFontSize = 18 + DefaultTitle = "instruction.md" + DefaultHeader = "instruction.md" + DefaultChrome = "default" + DefaultClass = "backstage.popup" + DefaultCPS = 32 + DefaultTerm = "ghostty" +) + // Opts configure a popup: its size, typing speed, and the terminal used. type Opts struct { Width int @@ -7,12 +26,70 @@ type Opts struct { CPS int Term string // terminal command (e.g. "ghostty") FontSize int + Title string + Header string + Chrome string + Class string } // Prompter shows a floating instruction box that types text on screen. type Prompter interface { + // Preflight verifies the driver's runtime dependencies are available before + // any recording starts, so a scene with dialog steps fails fast with a clear + // error instead of producing a video with no narration. term is the + // configured terminal command (empty selects the driver default). + Preflight(term string) error + // PreflightHypr verifies only the compositor dependency (hyprctl), not the + // terminal. A scene that records a live transition overlay but no dialog uses + // the compositor for the overlay but never opens the prompter terminal, so it + // must fail fast on a missing compositor without requiring the terminal. + PreflightHypr() error // Show opens the popup and types text into it; it stays until Close. Show(text string, opts Opts) error // Close dismisses the current popup. Close() error } + +// ValidateTitle rejects terminal/window metadata controls, including newlines, +// ESC/OSC sequences, DEL, and C1 controls. +func ValidateTitle(title string) error { + if r, ok := unsafeTitleRune(title); ok { + return fmt.Errorf("popup.style.title must not contain control character U+%04X", r) + } + return nil +} + +// SafeTitle removes metadata controls before a title is passed to a terminal. +func SafeTitle(title string) string { + var b strings.Builder + for len(title) > 0 { + r, size := utf8.DecodeRuneInString(title) + if r == utf8.RuneError && size == 1 { + title = title[size:] + continue + } + if !isTitleControl(r) { + b.WriteRune(r) + } + title = title[size:] + } + return b.String() +} + +func unsafeTitleRune(title string) (rune, bool) { + for len(title) > 0 { + r, size := utf8.DecodeRuneInString(title) + if r == utf8.RuneError && size == 1 { + return rune(title[0]), true + } + if isTitleControl(r) { + return r, true + } + title = title[size:] + } + return 0, false +} + +func isTitleControl(r rune) bool { + return r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) +} diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index da28444..88342c9 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -1,8 +1,12 @@ package prompter import ( + "os" + "os/exec" + "path/filepath" "strings" "testing" + "time" ) func TestTypewriterOutput(t *testing.T) { @@ -43,4 +47,160 @@ func TestShquote(t *testing.T) { } } +func TestHeaderCommand(t *testing.T) { + if got := headerCommand("prompt", "none"); got != "" { + t.Errorf("none header = %q, want empty", got) + } + if got := headerCommand("prompt", "minimal"); !strings.Contains(got, "prompt") || strings.Contains(got, "──") { + t.Errorf("minimal header unexpected: %q", got) + } + if got := headerCommand("prompt", "default"); !strings.Contains(got, "prompt") || !strings.Contains(got, "──") { + t.Errorf("default header unexpected: %q", got) + } + if got := headerCommand(`\033[31m`, "minimal"); strings.Contains(got, "%b") { + t.Errorf("header command must not use %%b for configured text: %q", got) + } +} + +func TestHeaderCommandRendersConfiguredHeaderLiterally(t *testing.T) { + out, err := exec.Command("bash", "-lc", headerCommand(`\033[31m`, "minimal")).Output() + if err != nil { + t.Fatalf("header command: %v", err) + } + if got, want := string(out), "\n \\033[31m\n\n"; got != want { + t.Fatalf("escaped header output = %q, want %q", got, want) + } + + out, err = exec.Command("bash", "-lc", headerCommand("bad\x1b[31m", "minimal")).Output() + if err != nil { + t.Fatalf("header command with control byte: %v", err) + } + if strings.Contains(string(out), "\x1b") { + t.Fatalf("configured header emitted terminal escape byte: %q", out) + } + if !strings.Contains(string(out), `bad\x1b[31m`) { + t.Fatalf("configured header control byte was not rendered visibly: %q", out) + } +} + +func TestClassREFor(t *testing.T) { + if got := classREFor("backstage.demo"); got != `^(backstage\.demo)$` { + t.Errorf("classREFor = %q", got) + } + if got := classREFor(""); got != `^(backstage\.popup)$` { + t.Errorf("default classREFor = %q", got) + } +} + +func TestHyprPopupTracksAndCleansTerminalProcess(t *testing.T) { + dir := t.TempDir() + hyprLog := filepath.Join(dir, "hypr.log") + termPIDs := filepath.Join(dir, "term-pids") + termClosed := filepath.Join(dir, "term-closed") + installPrompterStub(t, dir, "hyprctl", `#!/bin/sh +if [ "$1" = "clients" ]; then + printf '[{"class": "backstage.popup"}]' + exit 0 +fi +printf '%s\n' "$*" >> "$HYPR_LOG" +exit 0 +`) + installPrompterStub(t, dir, "termstub", `#!/bin/sh +printf '%s\n' "$$" >> "$TERM_PIDS" +trap 'printf "%s\n" "$$" >> "$TERM_CLOSED"; exit 0' TERM INT HUP +while :; do sleep 1; done +`) + t.Setenv("HYPR_LOG", hyprLog) + t.Setenv("TERM_PIDS", termPIDs) + t.Setenv("TERM_CLOSED", termClosed) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + oldGrace := popupTerminateGrace + popupTerminateGrace = 200 * time.Millisecond + defer func() { popupTerminateGrace = oldGrace }() + + h := &Hypr{Self: "/bin/true"} + defer h.Close() + opts := Opts{Term: "termstub", Width: 640, Height: 240} + if err := h.Show("first", opts); err != nil { + t.Fatalf("first Show: %v", err) + } + waitForPrompterLines(t, termPIDs, 1) + h.mu.Lock() + firstTmp := h.tmp + setpgid := h.cmd != nil && h.cmd.SysProcAttr != nil && h.cmd.SysProcAttr.Setpgid + h.mu.Unlock() + if firstTmp == "" { + t.Fatal("Show should retain the popup temp file") + } + if !setpgid { + t.Fatal("Show should start the terminal in its own process group") + } + + if err := h.Show("second", opts); err != nil { + t.Fatalf("second Show: %v", err) + } + waitForPrompterLines(t, termPIDs, 2) + waitForPrompterLines(t, termClosed, 1) + if _, err := os.Stat(firstTmp); !os.IsNotExist(err) { + t.Fatalf("previous popup temp file still exists: %v", err) + } + + if err := h.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + waitForPrompterLines(t, termClosed, 2) + h.mu.Lock() + stateCleared := h.tmp == "" && h.class == "" && h.cmd == nil && h.waitCh == nil + h.mu.Unlock() + if !stateCleared { + t.Fatal("Close should clear tracked popup state") + } + b, err := os.ReadFile(hyprLog) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `dispatch closewindow class:^(backstage\.popup)$`) { + t.Fatalf("hyprctl closewindow by class not recorded in log:\n%s", b) + } +} + +func TestTitleSafety(t *testing.T) { + if err := ValidateTitle("instruction.md"); err != nil { + t.Fatalf("normal title rejected: %v", err) + } + for _, title := range []string{"line\nbreak", "bad\x1b]0;evil\x07", "c1\u009d"} { + if err := ValidateTitle(title); err == nil { + t.Fatalf("ValidateTitle(%q) should reject controls", title) + } + } + if got, want := SafeTitle("bad\x1b]0;evil\x07"), "bad]0;evil"; got != want { + t.Fatalf("SafeTitle = %q, want %q", got, want) + } +} + var _ Prompter = (*Hypr)(nil) + +func installPrompterStub(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil { + t.Fatal(err) + } +} + +func waitForPrompterLines(t *testing.T, path string, want int) []string { + t.Helper() + deadline := time.After(2 * time.Second) + for { + if b, err := os.ReadFile(path); err == nil { + lines := strings.Fields(string(b)) + if len(lines) >= want { + return lines + } + } + select { + case <-deadline: + t.Fatalf("timed out waiting for %d lines in %s", want, path) + case <-time.After(10 * time.Millisecond): + } + } +} diff --git a/internal/recorder/gpu.go b/internal/recorder/gpu.go index 76c5528..8970c87 100644 --- a/internal/recorder/gpu.go +++ b/internal/recorder/gpu.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "strconv" + "sync" "syscall" "time" ) @@ -14,16 +15,23 @@ import ( // gpuBinary is omarchy's hardware screen recorder (the Alt+PrintScreen tool). const gpuBinary = "gpu-screen-recorder" +var ( + recorderStopGrace = 5 * time.Second + recorderKillWait = 2 * time.Second +) + // GPU records a whole monitor with gpu-screen-recorder: hardware-encoded, CFR, // no interactive region picker. Ports rec.sh. type GPU struct { Monitor string FPS int - cmd *exec.Cmd - out string - done chan error - wait error + mu sync.Mutex + cmd *exec.Cmd + out string + done chan struct{} + wait error + stopRequested bool } // NewGPU returns a recorder targeting the given monitor at fps. @@ -49,22 +57,44 @@ func (g *GPU) Start(outPath string) error { if err := os.MkdirAll(filepath.Dir(outPath), 0o700); err != nil { return err } + cmd := exec.Command(gpuBinary, g.args(outPath)...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + done := make(chan struct{}) + + g.mu.Lock() + if g.stopRequested { + g.mu.Unlock() + return fmt.Errorf("%s start cancelled", gpuBinary) + } g.out = outPath - g.cmd = exec.Command(gpuBinary, g.args(outPath)...) - if err := g.cmd.Start(); err != nil { + g.cmd = cmd + g.done = done + g.wait = nil + g.stopRequested = false + if err := cmd.Start(); err != nil { + g.cmd = nil + g.done = nil + g.mu.Unlock() return fmt.Errorf("start %s: %w", gpuBinary, err) } - g.done = make(chan error, 1) - g.wait = nil - go func() { g.done <- g.cmd.Wait() }() + go func() { + err := cmd.Wait() + g.mu.Lock() + g.wait = err + close(done) + g.mu.Unlock() + }() + g.mu.Unlock() for i := 0; i < 50; i++ { // ~10s if _, err := os.Stat(outPath); err == nil { return nil } select { - case err := <-g.done: - g.done = nil - g.wait = err + case <-done: + if _, err := os.Stat(outPath); err == nil { + return nil + } + err := g.waitErr() if err == nil { return fmt.Errorf("%s exited before creating %s", gpuBinary, outPath) } @@ -76,54 +106,113 @@ func (g *GPU) Start(outPath string) error { return fmt.Errorf("%s did not create %s within 10s", gpuBinary, outPath) } -// Stop SIGINTs the recorder so the mp4 is finalized, then waits for it to exit. +// Stop SIGINTs the recorder so the mp4 is finalized, then waits with a bounded +// SIGKILL fallback so interrupt cleanup cannot hang forever. func (g *GPU) Stop() (string, error) { - if g.cmd == nil || g.cmd.Process == nil { + g.mu.Lock() + out := g.out + cmd := g.cmd + if cmd == nil { + g.stopRequested = true + } + g.mu.Unlock() + if cmd == nil || cmd.Process == nil { return "", nil } err := g.stopProcess() if err != nil { - return g.out, err + return out, err } - fi, err := os.Stat(g.out) + fi, err := os.Stat(out) if err != nil { - return g.out, fmt.Errorf("recording output missing: %w", err) + return out, fmt.Errorf("recording output missing: %w", err) } if fi.Size() == 0 { - return g.out, fmt.Errorf("recording output is empty: %s", g.out) + return out, fmt.Errorf("recording output is empty: %s", out) } - return g.out, nil + return out, nil } func (g *GPU) stopProcess() error { - if g.done == nil { - return unexpectedWait(g.wait) + g.mu.Lock() + cmd := g.cmd + done := g.done + wait := g.wait + g.mu.Unlock() + if cmd == nil || cmd.Process == nil || done == nil { + return unexpectedWait(wait) } select { - case err := <-g.done: - g.done = nil - g.wait = err - return unexpectedWait(err) + case <-done: + return unexpectedWait(g.waitErr()) default: } - if err := g.cmd.Process.Signal(syscall.SIGINT); err != nil && !errors.Is(err, os.ErrProcessDone) { + if err := signalRecorder(cmd, syscall.SIGINT); err != nil { return fmt.Errorf("stop %s: %w", gpuBinary, err) } - err := <-g.done - g.done = nil - g.wait = err - return unexpectedWait(err) + select { + case <-done: + return unexpectedWait(g.waitErr()) + case <-time.After(recorderStopGrace): + } + + timeoutErr := fmt.Errorf("stop %s: did not exit within %s after SIGINT; sent SIGKILL", gpuBinary, recorderStopGrace) + if err := signalRecorder(cmd, syscall.SIGKILL); err != nil { + return errors.Join(timeoutErr, fmt.Errorf("kill %s: %w", gpuBinary, err)) + } + select { + case <-done: + if err := expectedWait(g.waitErr(), syscall.SIGINT, syscall.SIGKILL); err != nil { + return errors.Join(timeoutErr, err) + } + return timeoutErr + case <-time.After(recorderKillWait): + return errors.Join(timeoutErr, fmt.Errorf("%s did not exit after SIGKILL", gpuBinary)) + } +} + +func signalRecorder(cmd *exec.Cmd, sig syscall.Signal) error { + if cmd == nil || cmd.Process == nil { + return nil + } + pid := cmd.Process.Pid + if pid <= 0 { + return nil + } + if err := syscall.Kill(-pid, sig); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil + } + if sigErr := cmd.Process.Signal(sig); sigErr != nil && !errors.Is(sigErr, os.ErrProcessDone) { + return errors.Join(err, sigErr) + } + } + return nil +} + +func (g *GPU) waitErr() error { + g.mu.Lock() + defer g.mu.Unlock() + return g.wait } func unexpectedWait(err error) error { + return expectedWait(err, syscall.SIGINT) +} + +func expectedWait(err error, signals ...syscall.Signal) error { if err == nil { return nil } var exitErr *exec.ExitError if errors.As(err, &exitErr) { if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { - if status.Signaled() && status.Signal() == syscall.SIGINT { - return nil + if status.Signaled() { + for _, sig := range signals { + if status.Signal() == sig { + return nil + } + } } } } diff --git a/internal/recorder/gpu_test.go b/internal/recorder/gpu_test.go index b2849fb..7bcb76e 100644 --- a/internal/recorder/gpu_test.go +++ b/internal/recorder/gpu_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestGPUArgs(t *testing.T) { @@ -27,6 +28,23 @@ func TestGPUStartFailsWhenProcessExitsBeforeOutput(t *testing.T) { } } +func TestGPUStopBeforeStartCancelsStart(t *testing.T) { + installGPUStub(t, `printf warm > "$out" +exit 0 +`) + out := filepath.Join(t.TempDir(), "out.mp4") + g := NewGPU("eDP-1", 30) + if _, err := g.Stop(); err != nil { + t.Fatalf("Stop before Start: %v", err) + } + if err := g.Start(out); err == nil || !strings.Contains(err.Error(), "start cancelled") { + t.Fatalf("Start after Stop error = %v, want start cancelled", err) + } + if _, err := os.Stat(out); err == nil { + t.Fatal("cancelled Start should not spawn the recorder") + } +} + func TestGPUStopFinalizesNonEmptyOutput(t *testing.T) { installGPUStub(t, `printf warm > "$out" trap 'printf final > "$out"; exit 0' INT @@ -37,6 +55,12 @@ while true; do sleep 1; done if err := g.Start(out); err != nil { t.Fatalf("Start: %v", err) } + g.mu.Lock() + setpgid := g.cmd != nil && g.cmd.SysProcAttr != nil && g.cmd.SysProcAttr.Setpgid + g.mu.Unlock() + if !setpgid { + t.Fatal("Start must place recorder in its own process group") + } if _, err := g.Stop(); err != nil { t.Fatalf("Stop: %v", err) } @@ -45,6 +69,47 @@ while true; do sleep 1; done } } +func TestGPUStopDuringStartFinalizesOutput(t *testing.T) { + installGPUStub(t, `printf ready > "$out".ready +trap 'printf final > "$out"; exit 0' INT +while true; do sleep 1; done +`) + out := filepath.Join(t.TempDir(), "out.mp4") + g := NewGPU("eDP-1", 30) + startErr := make(chan error, 1) + go func() { startErr <- g.Start(out) }() + + ready := out + ".ready" + deadline := time.After(2 * time.Second) + for { + if _, err := os.Stat(ready); err == nil { + break + } + select { + case err := <-startErr: + t.Fatalf("Start returned before recorder was stopped: %v", err) + case <-deadline: + t.Fatal("recorder stub did not start") + case <-time.After(10 * time.Millisecond): + } + } + + if _, err := g.Stop(); err != nil { + t.Fatalf("Stop during Start: %v", err) + } + select { + case err := <-startErr: + if err != nil { + t.Fatalf("Start after Stop: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Start did not unblock after Stop") + } + if b, err := os.ReadFile(out); err != nil || string(b) != "final" { + t.Fatalf("final output = %q, %v", b, err) + } +} + func TestGPUStopRejectsEmptyOutput(t *testing.T) { installGPUStub(t, `: > "$out" trap 'exit 0' INT @@ -60,6 +125,33 @@ while true; do sleep 1; done } } +func TestGPUStopForceKillsIgnoredSIGINT(t *testing.T) { + oldStopGrace := recorderStopGrace + oldKillWait := recorderKillWait + recorderStopGrace = 50 * time.Millisecond + recorderKillWait = time.Second + defer func() { + recorderStopGrace = oldStopGrace + recorderKillWait = oldKillWait + }() + installGPUStub(t, `printf warm > "$out" +trap '' INT +while true; do sleep 1; done +`) + out := filepath.Join(t.TempDir(), "out.mp4") + g := NewGPU("eDP-1", 30) + if err := g.Start(out); err != nil { + t.Fatalf("Start: %v", err) + } + start := time.Now() + if _, err := g.Stop(); err == nil || !strings.Contains(err.Error(), "SIGKILL") { + t.Fatalf("Stop error = %v, want SIGKILL fallback", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("Stop waited too long for ignored SIGINT: %s", elapsed) + } +} + func installGPUStub(t *testing.T, body string) { t.Helper() dir := t.TempDir() diff --git a/internal/scene/loader.go b/internal/scene/loader.go index ccac007..0caec49 100644 --- a/internal/scene/loader.go +++ b/internal/scene/loader.go @@ -6,18 +6,28 @@ import ( "os" "path/filepath" "strings" + + "github.com/This-Is-NPC/backstage/internal/prompter" ) // configName is the project config filename. const configName = "backstage.json" -// Defaults mirror core.sh so a sparse config still works. +// Defaults mirror core.sh so a sparse config still works. Popup-style and CPS +// defaults are owned by the prompter package (the single source of truth) so +// config defaulting and the popup driver can't drift. const ( defMonitor = "eDP-1" defFPS = 30 defOut = "recordings" - defCPS = 32 - defTerm = "ghostty" + defCPS = prompter.DefaultCPS + defTerm = prompter.DefaultTerm + + defPopupFontSize = prompter.DefaultFontSize + defPopupTitle = prompter.DefaultTitle + defPopupHeader = prompter.DefaultHeader + defPopupChrome = prompter.DefaultChrome + defPopupClass = prompter.DefaultClass ) var defPopupSize = []int{1200, 560} @@ -73,7 +83,15 @@ func LoadProject(cfgPath string) (*Project, error) { return nil, fmt.Errorf("config %s: %w", cfgPath, err) } p.Dir = filepath.Dir(cfgPath) + // Validate the raw fontSize before defaulting: applyDefaults coerces 0 → the + // default, so a negative value is the only invalid raw input to reject here. + if p.Popup.Style.FontSize < 0 { + return nil, fmt.Errorf("popup.style.fontSize must not be negative") + } p.applyDefaults() + if err := p.ValidateConfig(); err != nil { + return nil, err + } for k, v := range p.Env { if err := ValidateEnvKey(k); err != nil { return nil, err @@ -99,6 +117,21 @@ func (p *Project) applyDefaults() { if p.Popup.CPS == 0 { p.Popup.CPS = defCPS } + if p.Popup.Style.FontSize == 0 { + p.Popup.Style.FontSize = defPopupFontSize + } + if p.Popup.Style.Title == "" { + p.Popup.Style.Title = defPopupTitle + } + if p.Popup.Style.Header == "" { + p.Popup.Style.Header = defPopupHeader + } + if p.Popup.Style.Chrome == "" { + p.Popup.Style.Chrome = defPopupChrome + } + if p.Popup.Style.Class == "" { + p.Popup.Style.Class = defPopupClass + } if p.Term == "" { p.Term = defTerm } @@ -108,6 +141,29 @@ func (p *Project) applyDefaults() { } } +// PopupClassFor returns the configured popup window class for a project config, +// tolerating an otherwise-invalid config. The kill/close path uses it so a popup +// opened with a custom class is reliably dismissible even when the rest of the +// config no longer validates. Returns the default class if the config can't be +// read, names no class, or names a class that fails the same validation +// ValidateConfig applies (popupClassRE) — so a class with spaces or shell +// metacharacters is never handed to hyprctl, even on this tolerant path. +func PopupClassFor(cfgPath string) string { + b, err := os.ReadFile(cfgPath) + if err != nil { + return defPopupClass + } + var p Project + if err := json.Unmarshal(b, &p); err != nil { + return defPopupClass + } + class := p.Popup.Style.Class + if class == "" || !popupClassRE.MatchString(class) { + return defPopupClass + } + return class +} + // Expand replaces ${PROJECT} and $PROJECT with the project root directory. func (p *Project) Expand(value string) string { r := strings.ReplaceAll(value, "${PROJECT}", p.Dir) diff --git a/internal/scene/paths.go b/internal/scene/paths.go index a1c5cab..c5b255d 100644 --- a/internal/scene/paths.go +++ b/internal/scene/paths.go @@ -1,11 +1,14 @@ package scene import ( + "errors" "fmt" "os" + "os/exec" "path/filepath" "regexp" "strings" + "syscall" ) var ( @@ -30,8 +33,8 @@ func ValidateEnvKey(key string) error { return nil } -// SafePath joins project-relative path parts and rejects absolute paths or .. -// escapes from the project root. +// SafePath joins project-relative path parts and rejects absolute paths, .. +// escapes, or symlink ancestors that escape the project root. func (p *Project) SafePath(parts ...string) (string, error) { root, err := filepath.Abs(p.Dir) if err != nil { @@ -57,22 +60,129 @@ func (p *Project) SafePath(parts ...string) (string, error) { } evalRoot, err := filepath.EvalSymlinks(root) if err != nil { - evalRoot = root + return "", fmt.Errorf("project root %q: %w", root, err) } - check := abs - if _, err := os.Lstat(check); err != nil { - check = filepath.Dir(check) + if rel == "." { + return evalRoot, nil } - if evalCheck, err := filepath.EvalSymlinks(check); err == nil { - rel, err := filepath.Rel(evalRoot, evalCheck) + check := root + // resolved tracks the canonical real path: the symlink-resolved prefix for + // components that exist, plus any not-yet-existing tail components appended + // verbatim. Returning this instead of the raw abs path closes the TOCTOU gap + // where a checked component is swapped to an escaping symlink before use. + resolved := evalRoot + appending := false + for _, elem := range strings.Split(rel, string(filepath.Separator)) { + if elem == "" || elem == "." { + continue + } + if appending { + resolved = filepath.Join(resolved, elem) + continue + } + check = filepath.Join(check, elem) + if _, err := os.Lstat(check); err != nil { + if os.IsNotExist(err) { + // This and all remaining components do not exist yet; append + // them verbatim to the resolved prefix. + resolved = filepath.Join(resolved, elem) + appending = true + continue + } + return "", err + } + evalCheck, err := filepath.EvalSymlinks(check) if err != nil { return "", err } - if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + realRel, err := filepath.Rel(evalRoot, evalCheck) + if err != nil { + return "", err + } + if realRel == ".." || strings.HasPrefix(realRel, ".."+string(filepath.Separator)) { return "", fmt.Errorf("path %q escapes project root %q through symlink", abs, root) } + resolved = evalCheck + } + return resolved, nil +} + +// SetProcessGroup starts cmd in a new process group so cleanup can kill the +// command and any children it spawned. +func SetProcessGroup(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +// PropEnv is the process environment plus the project's exported env block. It is +// the single source of truth for the environment props/hooks run with. +func (p *Project) PropEnv() []string { + env := os.Environ() + for k, v := range p.Env { + env = append(env, k+"="+v) + } + return env +} + +// PropCommand builds an *exec.Cmd for a project-relative executable: it resolves +// rel through SafePath, sets the project dir + env, and inherits stdio. Callers +// that need lifecycle control (start, kill, wait separately) use this; callers +// that just want to block use RunProp. Centralizing the build keeps env/cwd/arg +// handling from drifting between the engine and the production pipeline. +func (p *Project) PropCommand(rel string, args []string) (*exec.Cmd, error) { + path, err := p.SafePath(rel) + if err != nil { + return nil, err + } + cmd := exec.Command(path, args...) + cmd.Dir = p.Dir + cmd.Env = p.PropEnv() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + SetProcessGroup(cmd) + return cmd, nil +} + +// KillProcessGroup kills a prop command and any children it spawned. PropCommand +// starts commands in a new process group, so interrupt cleanup must target the +// group rather than only the direct child. +func KillProcessGroup(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + pid := cmd.Process.Pid + if pid <= 0 { + return nil + } + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil + } + if killErr := cmd.Process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { + return errors.Join(err, killErr) + } + return err + } + return nil +} + +// RunProp builds and runs a project-relative executable to completion, wrapping a +// failure with label. It is the shared "run project prop" path for the engine's +// prop/transition steps. +func (p *Project) RunProp(label, rel string, args []string) error { + cmd, err := p.PropCommand(rel, args) + if err != nil { + return err } - return abs, nil + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %w", label, err) + } + return nil } // ScenePathSafe returns the project-contained path for a named scene. diff --git a/internal/scene/scene_test.go b/internal/scene/scene_test.go index dd03341..07ba351 100644 --- a/internal/scene/scene_test.go +++ b/internal/scene/scene_test.go @@ -64,11 +64,86 @@ func TestExpandAndDefaults(t *testing.T) { if p.Popup.CPS != defCPS || len(p.Popup.Size) != 2 { t.Errorf("popup defaults not applied: %+v", p.Popup) } + if p.Popup.Style.FontSize != defPopupFontSize || p.Popup.Style.Title != defPopupTitle || + p.Popup.Style.Header != defPopupHeader || p.Popup.Style.Chrome != defPopupChrome || + p.Popup.Style.Class != defPopupClass { + t.Errorf("popup style defaults not applied: %+v", p.Popup.Style) + } if p.Term != defTerm { t.Errorf("term default = %s, want %s", p.Term, defTerm) } } +func TestPopupStyleValidation(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path + } + + badChrome := write("bad-chrome.json", `{ + "popup": {"style": {"chrome": "loud"}}, + "layouts": {"solo": {"panes": [{"name": "t"}]}} + }`) + if _, err := LoadProject(badChrome); err == nil { + t.Error("expected invalid chrome to fail") + } + + badClass := write("bad-class.json", `{ + "popup": {"style": {"class": "bad class"}}, + "layouts": {"solo": {"panes": [{"name": "t"}]}} + }`) + if _, err := LoadProject(badClass); err == nil { + t.Error("expected invalid class to fail") + } + + badTitle := write("bad-title.json", `{ + "popup": {"style": {"title": "safe\u001b]0;evil\u0007"}}, + "layouts": {"solo": {"panes": [{"name": "t"}]}} + }`) + if _, err := LoadProject(badTitle); err == nil { + t.Error("expected control characters in title to fail") + } +} + +func TestPopupClassFor(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path + } + + // A valid custom class is returned as-is. + good := write("good.json", `{"popup": {"style": {"class": "my.popup-1"}}}`) + if got := PopupClassFor(good); got != "my.popup-1" { + t.Errorf("PopupClassFor(valid) = %q, want %q", got, "my.popup-1") + } + + // A class with spaces/shell metacharacters must NOT be passed through to + // hyprctl even on this tolerant path; fall back to the default class. + bad := write("bad.json", `{"popup": {"style": {"class": "evil class; rm -rf /"}}}`) + if got := PopupClassFor(bad); got != defPopupClass { + t.Errorf("PopupClassFor(unsafe) = %q, want default %q", got, defPopupClass) + } + + // No class / missing file → default. + none := write("none.json", `{"popup": {"style": {}}}`) + if got := PopupClassFor(none); got != defPopupClass { + t.Errorf("PopupClassFor(empty) = %q, want default %q", got, defPopupClass) + } + if got := PopupClassFor(filepath.Join(dir, "missing.json")); got != defPopupClass { + t.Errorf("PopupClassFor(missing) = %q, want default %q", got, defPopupClass) + } +} + func TestResetDefault(t *testing.T) { s := &Scene{} if !s.ResetEnabled() { @@ -82,13 +157,21 @@ func TestResetDefault(t *testing.T) { } func TestValidate(t *testing.T) { + dir := t.TempDir() + live := filepath.Join(dir, "live.sh") + if err := os.WriteFile(live, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } p := &Project{ - Layouts: map[string]Layout{"solo": {Panes: []Pane{{Name: "t"}}}}, - Aliases: map[string]Alias{"okt-terminal": {Action: "keys", Target: "okt"}}, + Dir: dir, + Layouts: map[string]Layout{"solo": {Panes: []Pane{{Name: "t"}}}, "screen": {Panes: []Pane{}}}, + Aliases: map[string]Alias{"okt-terminal": {Action: "keys", Target: "okt"}}, + Transitions: map[string]Transition{"chapter": {Live: LiveTransition{Prop: "live.sh"}}}, } ok := &Scene{Name: "ok", Layout: "solo", Steps: []Step{ {Action: "dialog", Value: "hi"}, {Action: "okt-terminal", Commands: []string{"m"}}, // alias + {Action: "transition", Value: "chapter"}, }} if err := ok.Validate(p); err != nil { t.Errorf("valid scene rejected: %v", err) @@ -104,10 +187,108 @@ func TestValidate(t *testing.T) { t.Error("expected unknown-action error") } + badTransition := &Scene{Name: "x", Layout: "solo", Steps: []Step{{Action: "transition", Value: "missing"}}} + if err := badTransition.Validate(p); err == nil { + t.Error("expected unknown-transition error") + } + noSteps := &Scene{Name: "x", Layout: "solo"} if err := noSteps.Validate(p); err == nil { t.Error("expected no-steps error") } + + screenOnly := &Scene{Name: "screen", Layout: "screen", Steps: []Step{ + {Action: "transition", Value: "chapter"}, + {Action: "dialog", Value: "hi"}, + }} + if err := screenOnly.Validate(p); err != nil { + t.Errorf("screen-only scene rejected: %v", err) + } + badPaneAction := &Scene{Name: "x", Layout: "screen", Steps: []Step{{Action: "run", Value: "date"}}} + if err := badPaneAction.Validate(p); err == nil { + t.Error("expected pane action on screen-only layout to fail") + } +} + +func TestValidateTransitionModes(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "live.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + p := &Project{Dir: dir, Transitions: map[string]Transition{ + "offline": {Cmd: "render --out {{out}}"}, + "live": {Live: LiveTransition{Prop: "live.sh"}}, + "both": {Cmd: "render --out {{out}}", Live: LiveTransition{Prop: "live.sh"}}, + "empty": {}, + "badcmd": {Cmd: "render"}, + }} + for _, name := range []string{"offline", "live", "both"} { + if err := p.ValidateTransition(name); err != nil { + t.Errorf("%s should validate: %v", name, err) + } + } + for _, name := range []string{"empty", "badcmd", "missing"} { + if err := p.ValidateTransition(name); err == nil { + t.Errorf("%s should fail validation", name) + } + } + // RenderMode precedence: live wins over offline; an offline-only transition is + // not live; a malformed offline cmd is rejected even alongside a live block. + if p.Transitions["live"].RenderMode() != RenderLive { + t.Error("live transition should report RenderLive") + } + if p.Transitions["offline"].RenderMode() != RenderOffline { + t.Error("offline-only transition should report RenderOffline") + } + if p.Transitions["both"].RenderMode() != RenderLive { + t.Error("transition with both should prefer live (RenderLive)") + } +} + +func TestValidateTransitionOfflineCmdWithLive(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "live.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + p := &Project{Dir: dir, Transitions: map[string]Transition{ + // malformed offline cmd (no {{out}}) PLUS a live block: the offline cmd + // must still be validated rather than masked by the live block. + "badboth": {Cmd: "render", Live: LiveTransition{Prop: "live.sh"}}, + }} + if err := p.ValidateTransition("badboth"); err == nil { + t.Error("offline cmd missing {{out}} should fail even when a live block exists") + } +} + +func TestFontSizeNegativeRejected(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path + } + neg := write("neg-font.json", `{ + "popup": {"style": {"fontSize": -5}}, + "layouts": {"solo": {"panes": [{"name": "t"}]}} + }`) + if _, err := LoadProject(neg); err == nil { + t.Error("negative fontSize should be rejected") + } + // explicit 0 is coerced to the default (not rejected). + zero := write("zero-font.json", `{ + "popup": {"style": {"fontSize": 0}}, + "layouts": {"solo": {"panes": [{"name": "t"}]}} + }`) + p, err := LoadProject(zero) + if err != nil { + t.Fatalf("fontSize 0 should default, got error: %v", err) + } + if p.Popup.Style.FontSize != defPopupFontSize { + t.Errorf("fontSize 0 should default to %d, got %d", defPopupFontSize, p.Popup.Style.FontSize) + } } func TestManifestPane(t *testing.T) { @@ -144,6 +325,40 @@ func TestSafePath(t *testing.T) { if _, err := p.SafePath("linked", "out.mp4"); err == nil { t.Fatal("SafePath should reject symlink escapes") } + if _, err := p.SafePath("linked", "newdir", "out.mp4"); err == nil { + t.Fatal("SafePath should reject symlink ancestor escapes with missing child directories") + } +} + +func TestPropCommandUsesProjectContextAndProcessGroup(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "live.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + p := &Project{Dir: dir, Env: map[string]string{"BACKSTAGE_TEST_ENV": "yes"}} + cmd, err := p.PropCommand("live.sh", []string{"--flag"}) + if err != nil { + t.Fatalf("PropCommand: %v", err) + } + if cmd.Dir != dir { + t.Errorf("PropCommand dir = %q, want %q", cmd.Dir, dir) + } + if len(cmd.Args) != 2 || cmd.Args[1] != "--flag" { + t.Errorf("PropCommand args = %v", cmd.Args) + } + foundEnv := false + for _, kv := range cmd.Env { + if kv == "BACKSTAGE_TEST_ENV=yes" { + foundEnv = true + break + } + } + if !foundEnv { + t.Errorf("PropCommand env missing project env: %v", cmd.Env) + } + if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid { + t.Fatalf("PropCommand must start props in their own process group") + } } func TestValidateNameAndEnvKey(t *testing.T) { diff --git a/internal/scene/types.go b/internal/scene/types.go index f58aad6..1326eb4 100644 --- a/internal/scene/types.go +++ b/internal/scene/types.go @@ -71,11 +71,70 @@ type RenderCfg struct { FPS int `json:"fps,omitempty"` } -// Transition is a clip rendered between two scenes by a full user command. The -// command must write an mp4 to {{out}}; Backstage also substitutes {{w}} {{h}} -// {{fps}} {{from}} {{to}}. Everything else is the user's (any tool, any params). +// ResolveRenderDims returns the (fps, w, h) a transition should target, applying +// the same config fallbacks the production pipeline uses so an in-scene live +// transition and a production segment can't drift. fps falls back to record.fps +// when render.fps is unset. w/h are 0 when render.w/h are unset: that means +// "monitor native", whose true pixel size is only knowable by probing a recorded +// clip — which the production pipeline does, but an in-scene step cannot without +// itself recording. Callers that need concrete pixels (production) probe; the +// in-scene path passes 0 through to the prop, which should treat 0 as native. +func (p *Project) ResolveRenderDims() (fps, w, h int) { + fps = p.Render.FPS + if fps == 0 { + fps = p.Record.FPS + } + return fps, p.Render.W, p.Render.H +} + +// Transition is a reusable production visual. It can be rendered offline by a +// command that writes an mp4 to {{out}}, or recorded live by running a prop while +// the recorder captures the screen. type Transition struct { - Cmd string `json:"cmd"` + Cmd string `json:"cmd,omitempty"` + Live LiveTransition `json:"live,omitempty"` + // Live transitions run a blocking project-relative prop. The prop owns its + // visual lifecycle: open the overlay/window, wait for animation, close, exit. +} + +// HasLive reports whether this transition should be recorded from the screen. +func (t Transition) HasLive() bool { return t.Live.Prop != "" } + +// HasOffline reports whether this transition can render an mp4 without staging. +func (t Transition) HasOffline() bool { return t.Cmd != "" } + +// RenderMode is how a transition produces its clip. It is the single source of +// truth for the precedence shared by production, in-scene steps, and validation. +type RenderMode int + +const ( + // RenderNone means the transition defines neither a live prop nor an offline cmd. + RenderNone RenderMode = iota + // RenderLive records the screen while a live prop drives the overlay. + RenderLive + // RenderOffline runs a command that writes the clip to {{out}}. + RenderOffline +) + +// RenderMode reports how this transition should be rendered. A live prop takes +// precedence over an offline cmd when both are present, so production and +// in-scene steps agree on which path runs. +func (t Transition) RenderMode() RenderMode { + switch { + case t.HasLive(): + return RenderLive + case t.HasOffline(): + return RenderOffline + default: + return RenderNone + } +} + +// LiveTransition configures a transition-as-prop, either as a production segment +// or as an in-scene overlay step. +type LiveTransition struct { + Prop string `json:"prop,omitempty"` + Args []string `json:"args,omitempty"` } // TransitionUse places a transition after a named scene in a production. @@ -102,10 +161,22 @@ type RecordCfg struct { Out string `json:"out,omitempty"` } -// PopupCfg sizes the instruction popup and sets its typing speed. +// PopupCfg sizes the instruction popup, sets its typing speed, and optionally +// styles the current Hyprland terminal Prompter. type PopupCfg struct { - Size []int `json:"size,omitempty"` // [w, h] - CPS int `json:"cps,omitempty"` // characters per second + Size []int `json:"size,omitempty"` // [w, h] + CPS int `json:"cps,omitempty"` // characters per second + Style PopupStyleCfg `json:"style,omitempty"` +} + +// PopupStyleCfg keeps the built-in Prompter intentionally small. Complex HTML, +// animation, and multi-box overlays belong to live transitions. +type PopupStyleCfg struct { + FontSize int `json:"fontSize,omitempty"` + Title string `json:"title,omitempty"` + Header string `json:"header,omitempty"` + Chrome string `json:"chrome,omitempty"` // default|minimal|none + Class string `json:"class,omitempty"` } // Hooks are user scripts (project-relative) the runner calls but never inspects. diff --git a/internal/scene/validate.go b/internal/scene/validate.go index 6a09e66..5a5751c 100644 --- a/internal/scene/validate.go +++ b/internal/scene/validate.go @@ -3,18 +3,45 @@ package scene import ( "fmt" "os" + "regexp" "strings" + + "github.com/This-Is-NPC/backstage/internal/prompter" ) // knownActions are the canonical step actions the engine understands. Aliases // (resolved from project config) must expand to one of these. var knownActions = map[string]bool{ - "dialog": true, - "run": true, - "type": true, - "keys": true, - "prop": true, - "wait": true, + "dialog": true, + "run": true, + "type": true, + "keys": true, + "prop": true, + "transition": true, + "wait": true, +} + +var popupClassRE = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + +// ValidateConfig checks project-level settings after defaults are applied. +func (p *Project) ValidateConfig() error { + style := p.Popup.Style + if style.FontSize < 0 { + return fmt.Errorf("popup.style.fontSize must not be negative") + } + switch style.Chrome { + case "default", "minimal", "none": + // ok + default: + return fmt.Errorf("popup.style.chrome must be one of default, minimal, none") + } + if style.Class == "" || !popupClassRE.MatchString(style.Class) { + return fmt.Errorf("popup.style.class must contain only letters, numbers, dot, underscore, and dash") + } + if err := prompter.ValidateTitle(style.Title); err != nil { + return err + } + return nil } // Validate checks a scene against its project: the layout must exist and every @@ -29,7 +56,8 @@ func (s *Scene) Validate(p *Project) error { if layout == "" { return fmt.Errorf("scene %q: no layout", s.Name) } - if _, ok := p.Layouts[layout]; !ok { + layoutCfg, ok := p.Layouts[layout] + if !ok { return fmt.Errorf("scene %q: layout %q not in config", s.Name, layout) } if len(s.Steps) == 0 { @@ -39,10 +67,33 @@ func (s *Scene) Validate(p *Project) error { if st.Action == "" { return fmt.Errorf("scene %q: step %d has no action", s.Name, i+1) } - if !knownActions[st.Action] { - if _, ok := p.Aliases[st.Action]; !ok { + action := st.Action + if !knownActions[action] { + al, ok := p.Aliases[action] + if !ok { return fmt.Errorf("scene %q: step %d unknown action %q", s.Name, i+1, st.Action) } + action = al.Action + if !knownActions[action] { + return fmt.Errorf("scene %q: step %d alias %q expands to unknown action %q", s.Name, i+1, st.Action, action) + } + } + if action == "transition" { + if st.Value == "" { + return fmt.Errorf("scene %q: step %d transition action needs value", s.Name, i+1) + } + // In-scene transition steps must run as a live overlay; require live + // mode in addition to the shared transition checks (a valid offline + // cmd is still validated when present so the rules can't diverge). + if err := p.ValidateTransition(st.Value); err != nil { + return fmt.Errorf("scene %q: step %d: %w", s.Name, i+1, err) + } + if t := p.Transitions[st.Value]; t.RenderMode() != RenderLive { + return fmt.Errorf("scene %q: step %d: transition %q has no live.prop", s.Name, i+1, st.Value) + } + } + if len(layoutCfg.Panes) == 0 && (action == "run" || action == "type" || action == "keys") { + return fmt.Errorf("scene %q: step %d action %q needs a layout with panes", s.Name, i+1, action) } } return nil @@ -93,14 +144,25 @@ func (p *Project) ValidateProduction(prod Production) error { return nil } -// ValidateTransition checks a transition is defined and writes to {{out}}. +// ValidateTransition checks a transition is defined and has at least one render +// path: an offline cmd that writes to {{out}}, or a live prop. Both blocks are +// validated whenever present, regardless of render-mode precedence, so a +// malformed offline cmd is never masked by an accompanying live block. func (p *Project) ValidateTransition(name string) error { t, ok := p.Transitions[name] if !ok { return fmt.Errorf("transition %q not in config", name) } - if !strings.Contains(t.Cmd, "{{out}}") { + if t.RenderMode() == RenderNone { + return fmt.Errorf("transition %q: define cmd or live.prop", name) + } + if t.HasOffline() && !strings.Contains(t.Cmd, "{{out}}") { return fmt.Errorf("transition %q: cmd must write to {{out}}", name) } + if t.HasLive() { + if _, err := p.SafePath(t.Live.Prop); err != nil { + return fmt.Errorf("transition %q live.prop: %w", name, err) + } + } return nil } diff --git a/internal/transition/transition.go b/internal/transition/transition.go index 1943663..77f5454 100644 --- a/internal/transition/transition.go +++ b/internal/transition/transition.go @@ -21,8 +21,10 @@ type Vars struct { FPS int // {{fps}} } -// substitute replaces the supported placeholders in cmd. -func substitute(cmd string, v Vars) string { +// Substitute replaces the supported placeholders in a command or argument. Values +// are inserted verbatim: offline transition commands are trusted project shell +// commands, and the config author owns quoting where shell word boundaries matter. +func Substitute(cmd string, v Vars) string { return strings.NewReplacer( "{{out}}", v.Out, "{{w}}", strconv.Itoa(v.W), @@ -33,24 +35,57 @@ func substitute(cmd string, v Vars) string { ).Replace(cmd) } -// Render substitutes placeholders, runs the command via the shell (so users can -// use pipes/redirection), and verifies a non-empty mp4 landed at v.Out. -func Render(cmd string, v Vars, env []string, dir string) error { - final := substitute(cmd, v) +// SubstituteArgs applies Substitute to every argument. +func SubstituteArgs(args []string, v Vars) []string { + out := make([]string, len(args)) + for i, a := range args { + out[i] = Substitute(a, v) + } + return out +} + +// SubstituteLiveArgs substitutes placeholders for a live prop's args. Unlike an +// offline command, a live prop does NOT own the clip file — the recorder writes +// {{out}}. So {{out}} is intentionally left empty here: handing the recorder's +// output path to a live prop would let it clobber the in-progress recording. +// Live props may still use {{w}}/{{h}}/{{fps}}/{{from}}/{{to}}. +func SubstituteLiveArgs(args []string, v Vars) []string { + lv := v + lv.Out = "" + return SubstituteArgs(args, lv) +} + +// BuildCommand substitutes placeholders and returns the shell command that renders +// a transition clip. Callers that need signal/process lifecycle control can start +// and wait it themselves, then call VerifyOutput. +func BuildCommand(cmd string, v Vars, env []string, dir string) *exec.Cmd { + final := Substitute(cmd, v) c := exec.Command("sh", "-c", final) c.Dir = dir c.Env = env c.Stdout = os.Stdout c.Stderr = os.Stderr + return c +} + +// Render substitutes placeholders, runs the command via the shell (so users can +// use pipes/redirection), and verifies a non-empty mp4 landed at v.Out. +func Render(cmd string, v Vars, env []string, dir string) error { + c := BuildCommand(cmd, v, env, dir) if err := c.Run(); err != nil { return fmt.Errorf("transition command failed: %w", err) } - fi, err := os.Stat(v.Out) + return VerifyOutput(v.Out) +} + +// VerifyOutput checks that a transition command wrote a non-empty mp4. +func VerifyOutput(out string) error { + fi, err := os.Stat(out) if err != nil { - return fmt.Errorf("transition wrote no output at %s: %w", v.Out, err) + return fmt.Errorf("transition wrote no output at %s: %w", out, err) } if fi.Size() == 0 { - return fmt.Errorf("transition output is empty: %s", v.Out) + return fmt.Errorf("transition output is empty: %s", out) } return nil } diff --git a/internal/transition/transition_test.go b/internal/transition/transition_test.go index 6e800e2..ec5c93b 100644 --- a/internal/transition/transition_test.go +++ b/internal/transition/transition_test.go @@ -8,11 +8,36 @@ import ( ) func TestSubstitute(t *testing.T) { - got := substitute("r --out {{out}} --size {{w}}x{{h}} --fps {{fps}} {{from}}->{{to}}", + got := Substitute("r --out {{out}} --size {{w}}x{{h}} --fps {{fps}} {{from}}->{{to}}", Vars{Out: "/tmp/c.mp4", W: 1920, H: 1080, FPS: 30, From: "a", To: "b"}) want := "r --out /tmp/c.mp4 --size 1920x1080 --fps 30 a->b" if got != want { - t.Errorf("substitute =\n %s\nwant\n %s", got, want) + t.Errorf("Substitute =\n %s\nwant\n %s", got, want) + } +} + +func TestSubstituteLeavesShellQuotingToTrustedCommand(t *testing.T) { + got := Substitute("render --title '{{from}}' --out {{out}}", + Vars{From: "alpha beta", Out: "/tmp/clip one.mp4"}) + want := "render --title 'alpha beta' --out /tmp/clip one.mp4" + if got != want { + t.Fatalf("Substitute = %q, want %q", got, want) + } +} + +func TestSubstituteLiveArgsOmitsOut(t *testing.T) { + // A live prop must not receive the recorder's output path; {{out}} stays empty. + got := SubstituteLiveArgs( + []string{"--out", "{{out}}", "--size", "{{w}}x{{h}}", "--from", "{{from}}"}, + Vars{Out: "/tmp/recording.mp4", W: 1920, H: 1080, FPS: 30, From: "a", To: "b"}) + want := []string{"--out", "", "--size", "1920x1080", "--from", "a"} + if len(got) != len(want) { + t.Fatalf("SubstituteLiveArgs len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("SubstituteLiveArgs[%d] = %q, want %q", i, got[i], want[i]) + } } }