Skip to content

Repository files navigation

Cover

rolemodel-openscreen

RoleModel brand layer for OpenScreen.

No fork. Everything here sits on top of the public .openscreen document format and the headless CLI. Nothing patches OpenScreen's source, so an upgrade is npm update, not a merge.

brand/tokens.json          palette, type, and scale — mirrors the Academy HyperFrames theme
brand/optics/optics.css    @rolemodel/optics, VERBATIM — never edit, never hand-merge
brand/optics/rolemodel-scales.css  GENERATED — only the scales Optics doesn't publish
brand/optics/manifest.json GENERATED — the pinned Optics version + hashes
brand/optics-tokens.json   the Figma export, now only for the RoleModel sub-brands
brand/wallpapers.json      wallpaper recipes — the editable source of truth
brand/wallpapers/          16 rendered backdrops + a contact sheet
presets/*.json             rolemodel · academy · lightning
lib/theme.mjs              load a preset, patch a project document
lib/annotations.mjs        branded title / lower-third / callout / watermark / zoom builders
lib/wallpaper.mjs          recipe -> canvas. One implementation, shared by the
                           Studio preview, the Studio export, and the batch build
lib/wallpaper-recipes.mjs  the default recipe set, derived from tokens
lib/make-wallpapers.mjs    batch-render brand/wallpapers.json to JPEG
lib/optics-css.mjs         vendor Optics; generate only what it lacks
lib/jobs.mjs               run the pipeline and stream it back to the browser
lib/script-parse.mjs       markdown -> speakable lines (served to the browser too)
lib/narration.mjs          per-line TTS, measured, into audio + an exact SRT
components/rm-video.js     custom elements for scenes — seekable, Optics-coloured
components/gallery.html    live gallery with a scrubber
components/scene.html      the scene template to copy
components/render-scene.mjs  scene -> MP4, frame by frame
lib/verify.mjs             assert the presets still match OpenScreen's own types
bin/rm-video.mjs           CLI
skill/SKILL.md             agent skill — record → brand → export, end to end

Use

node bin/rm-video.mjs presets

# appearance only
node bin/rm-video.mjs theme demo.openscreen --preset rolemodel --variant vertical

# appearance + title card + watermark
node bin/rm-video.mjs brand demo.openscreen \
  --preset academy --unit rails \
  --title "Active Record basics" --eyebrow "Week 4" \
  --watermark --duration-ms 240000

openscreen export demo.openscreen -o demo.mp4 --auto-zoom --json

Build chain

npm run build     # sync-brand -> optics.css -> wallpapers
npm run check     # the same three, as assertions — this is what CI runs
npm run dev       # Studio with live reload
npm run studio    # Studio, plain

npm run dev is node --watch over bin/ and lib/ plus a live-reload stream to the browser, so editing a panel and seeing it are the same motion. Changes to presets/ and brand/ reload too. Nothing is watched in a normal run.

Running things

The Studio runs the pipeline rather than describing it: Record, Make, and From a test all hand you a Run button, and the output streams into Console while it happens. Commands are built server-side from an allowlist of binaries (lib/jobs.mjs) and passed as an argv array, so no string ever reaches a shell.

If you want a prompt to type into, that is opt-in:

rm-studio --shell

Narration

A markdown script becomes a voice track and a subtitle file that cannot drift from it.

rm-voice feeney-cable-rail-promo --script opener --voice af_nova
rm-voice --voices

Or rm-studio → Voice, which previews exactly which lines will be spoken before you commit to a synth pass.

The obvious pipeline — synthesise the whole script, then run the audio back through Whisper for timings — is wrong. You already know the words; sending them through speech recognition to get them back mis-hears product names and costs a pass. So this synthesises one clip per line, measures each one, and writes the SRT from durations it already has. Timings are exact by construction, and a copy edit re-synthesises only the lines that changed.

Voices are Kokoro via hyperframes tts — local, no API key, no per-character billing, and nothing about an unreleased client product leaves the machine. First run needs pip install kokoro-onnx soundfile and downloads ~27MB.

Putting narration on a render

rm-mux --video demo.mp4 --audio narration.wav --srt narration.srt -o final.mp4

A render and a narration track are on different clocks. recast compresses idle time — a five-second interaction becomes 3.8 seconds — while narration is however long the words take, which was 22 seconds for the same demo. Burn a 22-second subtitle track into a 3.8-second render and you get cue 1 held for the whole clip and the rest silently dropped. It looks like it worked.

rm-mux reconciles them: within 25% it pads the shorter one; when narration is longer it slows the video up to --max-stretch, then holds the last frame and tells you, because the real fix is a shorter script or a demo with more in it. Subtitles are burned onto the final timeline, after the stretch.

The Studio wires this automatically: if a project has narration matching the render name, the From-a-test panel returns two steps and recast skips its own burn-in.

Three ways into a video

input tool stays current?
Record your screen openscreen no — re-record by hand
Make a script or a URL HyperFrames, via Claude re-run the brief
From a test a Playwright trace playwright-recast yes — re-run the test

The third one is the interesting one. A trace already contains actions, screenshots, network waits and cursor positions, so a demo cut from it can be regenerated on every deploy and can never drift from the UI it documents.

Components

Custom elements for HyperFrames scenes — title cards, browser chrome, lower thirds, callouts, stats, build-on lists. rm-studio → Components, or open components/gallery.html.

<rm-scene wallpaper="brand/wallpapers/rm-dark-dotgrid">
  <rm-title at="0" for="2600" eyebrow="Product tour" title="Estimating, in one pass"></rm-title>
  <rm-browser at="2600" w="68" url="app.rolemodelsoftware.com" image="shot.png"></rm-browser>
  <rm-callout at="4200" for="2600" x="62" y="38" text="Live pricing"></rm-callout>
</rm-scene>

Time is seeked, not played. Every animation is a paused CSS animation whose delay is driven from one --t property, so RM.seek(2400) puts the whole scene at exactly that instant and frame N is identical on every render. A renderer steps seek and grabs frames; nothing depends on when it happened to look.

That is also why the two animations on a component write to different registered custom properties rather than both to opacity — with fill-mode: both, the exit animation's backwards fill silently wins, and every component renders visible from frame 0. npm run check asserts the contract: paused animations, no transitions, every delay positioned by --t, and no colour that isn't an Optics token.

node components/render-scene.mjs components/scene.html -o demo.mp4 --fps 30

Wallpapers

The backdrop behind the recording is the largest branded surface in the video, so it is editable rather than baked in. A wallpaper is a small JSON recipe in brand/wallpapers.json: base colour, a linear gradient, an optional directional tint, and a texture.

Edit them with a live 4K preview:

rm-studio            # -> Wallpapers

Save re-draws the frame at 3840x2160 in the browser and writes both the JPEG and the recipe. No Playwright needed on a designer's machine.

Batch-render the whole set (CI, or after editing brand/tokens.json):

npm install playwright
node lib/make-wallpapers.mjs            # render brand/wallpapers.json
node lib/make-wallpapers.mjs --reset    # re-derive recipes from tokens first

Colour is Optics, imported — not a copy of Optics. brand/optics/optics.css is @rolemodel/optics verbatim, vendored by lib/optics-css.mjs and pinned in brand/optics/manifest.json by version and hash. That matters because Optics does not ship hexes: every ramp is computed in CSS from --op-color-primary-h/s/l with light-dark(), so setting one hue re-tints all 486 tokens. An earlier version of this repo flattened the Figma export into 1160 static hexes, which looked identical and had lost exactly that — and drifted a little further with every Optics release.

The Figma export still has one job: brand/optics/rolemodel-scales.css carries the scales the open-source release does not publish (academy, lcad, docks, decks, railing, building, airfield, flow, and the secondary/tertiary/accent families). That file is filtered against the vendored package, so it can never shadow a real Optics token, and it shrinks by itself if Optics ever publishes those scales.

npm run optics:latest    # is there a newer Optics?
npm i -D @rolemodel/optics@latest && npm run optics

npm run check fails if the vendored copy was hand-edited, if the supplement is stale, if it shadows a published token, if a --op- token the UI spends resolves nowhere, or if lib/studio-ui.mjs grows a hand-written hex.

The edge is a solid border. border: { width, color, inset, radius } — width in px at 1920, scaled with the export. A gradient edge was wrong twice over: as a radial it produced the dark band along the bottom, and as a linear scrim it was still a fade where the brand calls for a line. rm-framed is the example.

No radial gradients. RoleModel's brand is linear — direction, not blobs. The first version used a radial vignette to settle the edges; at 16:9 that ellipse fell outside the frame along the bottom and read as a thick dark border under every recording. lib/verify.mjs fails the build if one comes back.

Check the presets against a specific OpenScreen version:

node lib/verify.mjs --openscreen /path/to/openscreen-checkout

verify reads ProjectEditorState, the webcam/quality enums, and the AnnotationRegion / AnnotationTextStyle / ZoomRegion interfaces straight out of the checkout and asserts every preset field and builder output against them. It matters because a wrong field name doesn't error — OpenScreen normalises it back to a default and the video silently looks stock.

What the presets decide

The stock defaults are fine for a hobby recording and wrong for client work. Each preset documents its reasoning inline; the ones worth knowing:

Setting Stock Ours Why
borderRadius 40 28 Echoes the 28px scene border in the Academy HyperFrames spec, so a capture cut beside a HyperFrames scene reads as one system
padding 50 62 The backdrop is the only branded surface in a screen demo
shadowIntensity 0.2 0.38 Stock reads flat against #293747
motionBlurAmount 0.2 0.18 Motion blur smears UI text during pans
cursorTheme default default Pinned deliberately — every bundled theme is a novelty cursor (Hello Kitty, Among Us, Pokémon). None belong in client work

Known limits

  • Wallpapers are referenced by absolute file:// URL. OpenScreen only rewrites its own bundled /wallpapers/wallpaperN.jpg paths and passes anything else through, so this works — but it means projects aren't portable across machines unless the toolkit sits at the same path. The clean fix is a user-wallpapers folder the app scans, which is a small upstream PR, not a reason to fork.
  • The document format is pre-1.0. Both the v7 AxcutDocument and the legacy v2 shape are handled, but a future bump could move fields. lib/verify.mjs is how you find out fast.
  • No custom cursor. A clean RoleModel cursor would need a theme contributed to the app itself. Worth doing upstream.
  • LightningCAD's signature came from Optics. lcad is a real scale in the export (#2b84f7), so the preset uses it rather than a placeholder.

Open design question

Does a sub-brand get its own motion language, or only its own colour, type, and surface signature over one shared system? These presets assume the latter — one system, distinct signatures. Deciding otherwise means maintaining a design system per sub-brand, which is a real ongoing cost for a team this size.

About

RoleModel Software brand layer for OpenScreen.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages