Skip to content

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Screenplay Creation Tool

Write a screenplay in plain text and get back a correctly formatted script.

The tool reads Fountain, the plain-text screenplay markup — plus one addition of its own, a way to write a speech on a single line, which is expanded back into ordinary Fountain whenever a .fountain file is saved. It lays the result out to the industry standard: a six-inch text column inside a one-and-a-half-inch left margin, 12 point Courier, 55 lines to the page, with character cues, parentheticals and dialogue each in their own column. It handles the things that make screenplay formatting fiddly — (MORE) and (CONT'D) across a page break, scene headings that must not be stranded at the foot of a page, dual dialogue in two columns, scene numbers in both margins.

It is a desktop application, built on a formatting engine that is a library in its own right — so what you read on screen is what comes out of the file. It also reads a script aloud: the Audio Drama menu turns a story file into a mixed radio play in ElevenLabs voices, pitched to the age on each line and treated to match how it is said.

The application

cargo run --release                          # start with an empty screenplay
cargo run --release -- examples/sample.fountain
cargo run --release -- examples/sample-drama.xml   # straight to the Audio Drama tab

Three panes over one document: the scene outline on the left, the Fountain editor in the middle, and the formatted script on the right, repaginating as you type. The status bar carries the page, scene and word counts. F1 lists the markup as well as the shortcuts.

Saving your work: .sct

⌘S saves a .sct document — the tool's own working format. It is the Fountain source with a short header in front of it recording where you had got to and what you know about the story, so reopening a draft puts the caret back where you left it, the outline back on the scene you were reading, and your notes back beside the script:

SCT/1
caret: 1402
scene: 3
world: Ashfen stands on a salt lake.\nThe Guild rules by writ.
character: MAYA
role: Salt-runner. The younger sister.
want: To buy her brother out of his indenture
voice: Short sentences. Never says what she means.
---
Title: The Last Bus

INT. BUS SHELTER - NIGHT
...

It is plain text on purpose. It diffs, it greps, and if the header is ever lost or damaged the file is still your screenplay — anything without the SCT/1 line is read as ordinary Fountain. Header values are one line each, so a note that runs to several lines writes its breaks as \n; unknown keys are ignored, so a file written by a later version still opens here.

Save as .fountain instead and you get ordinary Fountain with no header, for handing to another tool. That drops the notes as well as the caret, and the tool says so when it does it.

If the editor stops

While there is unsaved work the editor keeps a copy of it beside the settings, rewritten every twenty seconds and taken away the moment the work is saved. If the editor stops before it can take the copy away — a crash, a power cut, a laptop that went to sleep and never woke — the next start offers the work back.

Your own file is never written to. This is deliberately not auto-save, for three reasons that all point the same way:

  • Saving as .fountain has nowhere to put your notes, so a timer doing it would quietly throw the story bible away every twenty seconds.
  • Close without saving is a real thing to want after a bad hour, and an auto-save takes it away.
  • The draft most likely to be lost is the one that has never been saved at all, and that has no file to be written over.

So the copy is always a full .sct document whatever your own file is, which means the notes, the caret and the outline all survive a crash even for a draft you are keeping as plain Fountain. It is written owner-only, and written under a temporary name and renamed into place, so an interruption during the write cannot leave half a draft where a whole one was.

Platform Folder
macOS ~/Library/Application Support/openwrite/recovery
Windows %APPDATA%\openwrite\recovery
Linux ~/.config/openwrite/recovery

The question it asks takes three answers. Restore puts the work back in the editor, still unsaved and still not written to the file it came from — save it yourself when you have looked at it. Discard throws the copy away. Esc and the window's close button mean neither: they mean not now, so the copy stays and you are asked again next time. Losing a draft should take more than dismissing a window.

Nothing is announced while this works. The status bar is read aloud by a screen reader, so an editor that reported a save every twenty seconds would be unusable with one; the copies go to the debug log and nowhere else.

Typing dialogue quickly

Fountain writes a speech over three lines. This tool understands a one-line form as well:

MAYA: Forty-one.
DEV (not looking up): You know what this is.
/transition:cut to

which is the same screenplay as:

MAYA
Forty-one.

DEV
(not looking up)
You know what this is.

CUT TO:

A speech written this way ends at the end of its line — press Return and you are writing action again — but a line that is itself another cue carries on the exchange, so a fast back-and-forth needs no blank lines. Brackets before the colon become a stage direction under the cue, except (V.O.), (O.S.), (O.C.) and (CONT'D), which every screenplay prints on the cue line and so does this. The words have to be on the same line as the colon, which is what keeps FADE IN: the transition it has always been.

This part is not Fountain, and the tool does not pretend otherwise. MAYA: Forty-one. in a file handed to another program is a line of action, and that program would be right. So the shorthand is understood on the way in and written out in full on the way out: save as .fountain and what lands on disk is the three-line form. The tool's own .sct file keeps what you typed.

Characters and world

⌘K opens Characters and world — the things you know about the story that the script never says out loud.

At the top is the world: the setting and its rules, in your own words. Under it is one profile per character — role, age, what they want, how they talk, and whatever else needs writing down. None of it is printed. All of it is saved in the .sct file with the screenplay, so the notes and the draft cannot be separated.

There is no separate Save: the notes are part of the screenplay document, so ⌘S saves them with it, and the window says as much along the bottom.

Characters are keyed by the cue name they speak under, so a profile and the dialogue in the script are about the same person. Anybody who speaks but has no profile yet can be added in one press, and each profile shows how much that character is in the script already.

Ideas from a local model

⌘I opens Ideas, which puts one question to a language model running on your own machine:

  • What would they do? — three things this character could do next.
  • What would they say? — three lines they could say, in their voice.
  • What happens next? — three directions the story could go, written as Fountain synopsis lines (= like this), which are notes to yourself and never print.

The world, the character notes and the page you are on are what the model is told, which is the whole point: a model that has read your notes on the Guild and on Maya can answer as Maya, and one that has not cannot.

The answer lands in a box you can edit. Nothing reaches the screenplay until you press Insert at the caret.

This is optional in every sense. No request is made until you ask for one, and if no model server is running the window says so — and, when Ollama is installed but not started, offers a Start Ollama button rather than an error message about sockets. If it is not installed at all there is a link to go and get it. The rest of the tool carries on exactly as before either way. Two shapes of server are understood:

Ollama /api/tags and /api/generate
Anything OpenAI-compatible llama.cpp's server, LM Studio, Jan: /v1/models and /v1/chat/completions

The tool works out which is there by asking. By default it looks at http://127.0.0.1:11434, where Ollama listens; OPENWRITE_AI_URL points it somewhere else and OPENWRITE_AI_MODEL picks the model without going through the window. There is no TLS and no cloud provider: https:// addresses are refused rather than quietly accepted, and if the server you point it at is not on this machine the window says so in as many words, because that is the one case where an unpublished screenplay leaves the computer it was written on.

Builds made with --no-default-features have none of this compiled in at all.

Audio drama

The Audio Drama menu turns a story file into a radio play: every line read by an ElevenLabs voice, pitched and treated to match what the line says about itself, placed in the stereo picture, and mixed into one .wav.

A story is XML — a cast list and the dialogue:

<story model="1.0">
    <voices>
        <character id="1" name="ben" gender="male">VOICE_ID</character>
        <character id="2" name="faith" gender="female">VOICE_ID</character>
    </voices>
    <dialog>
        <character line="1" id="1" age="12" state="normal" pos="left">I don't remember how it happened</character>
        <character line="2" id="2" age="15" state="whisper" pos="right">What?</character>
    </dialog>
</story>

Between the <character> tags in <dialog> is the line itself. The attributes say how it is said:

age Shifts the pitch. A twelve-year-old does not sound like the adult whose voice was hired to play them.
state normal, whisper, scared, shout, angry, sad, excited, tired.
pos left, centre, right.

<voices> is where the ElevenLabs voice for each character goes. If your file has not got one, the tab makes it out of whoever speaks, offers you a voice for each of them, and Save story writes it back — so the casting is done once and kept in the file. The same is true of anybody who speaks but was left out of an existing cast list. examples/sample-drama.xml is a whole one to start from, and opening a .xml file — from the menu, from the command line, or through Open With — goes straight to the tab.

What is done to the audio. All of it is arithmetic on samples, on your machine, with nothing installed:

  • Age moves the pitch without moving the duration — the line still takes as long to say — by stretching the waveform in time and resampling it back. Twelve is about four semitones up; eighty is a little down and a little slower. The Age shift slider scales the lot, for when the voice you cast already sounds the right age and you do not want it applied twice.
  • state="scared" is a tremble: a slow wobble in pitch and one in loudness, which is what a frightened voice actually does. whisper thins the voice, drops it, and lays breath over it shaped by the words. shout drives it until it starts to break up. Each state also steers ElevenLabs itself — a frightened line is generated with low stability and high style, a whispered one with the opposite.
  • pos is a constant-power pan, so a voice does not get louder or quieter for having moved across the room. Not panned all the way: a voice hard against one ear sounds like a fault rather than like a room.

The tab lists every line with what is about to happen to it — "ben, 12, normal, +4.0 semitones, 2% faster, left" — before anything is sent, and reports anything odd about the file as notes rather than errors: an unknown state is read plainly, a missing <voices> section is built, and a voice id typed inside the tag rather than between the tags is taken anyway.

What it costs. One paid ElevenLabs request per line. The raw recording of each line is kept beside the finished play, named after a fingerprint of the three things ElevenLabs was told — the voice, the state and the words — so a later run that finds all three unchanged reuses it. Changing an age, a pos, or the age slider and re-recording therefore costs nothing at all: none of those change what was said, only what is done to it afterwards. Naming a recording after what is in it also means it survives editing, so adding a line at the top of a scene does not re-bill you for every line underneath it. Recordings no line asks for any more are swept up at the end of a run.

Getting a key. The tab cannot record without one, and the key is what ElevenLabs bills, so it is worth a minute of care.

  1. Make an account at elevenlabs.io. The free tier comes with a monthly character allowance and is enough to hear a scene; every line you record spends characters from it, and a line you have already recorded costs nothing to render again.
  2. Go to Settings → API keys, which is what the tab's Get a key button opens for you — it is elevenlabs.io/app/settings/api-keys.
  3. Create a key. Copy it there and then: ElevenLabs shows the whole key once and never again, so a key you did not copy is a key you have to replace.
  4. If it offers to restrict what the key may do, restrict it. This tool needs only text to speech and permission to read voices — nothing else it can be given is anything this tool will ever ask for. Setting a credit limit on the key is a good idea for the same reason.
  5. Paste it into ElevenLabs API key in the tab, and press Fetch voices. If the key is good, the cast list fills with voices to choose from; if it is not, the tab says what ElevenLabs said about it.

Where the key is kept. Pressing Remember puts it in the settings file beside your language choice. That file is plain text: anyone who can read it can spend the key, so do not copy it between machines, into a repository, or into a bug report. The file says as much at the top of itself, and is written so that only your account can read it.

Setting OPENWRITE_ELEVENLABS_KEY in the environment takes precedence over the saved one and is never written to disk, which is the way to use a key without leaving it lying about — on a shared machine, or when the key belongs to someone else. The tab says which of the two it is using. If a key does get out, delete it on the API keys page: a deleted key stops working immediately, and making a new one costs nothing.

OPENWRITE_ELEVENLABS_MODEL chooses the voice model (eleven_multilingual_v2 by default).

Nothing is sent until you press Record, and a recording can be stopped between lines. HTTPS is curl, as it is for the update check — there is no TLS stack and no other dependency in any of this. Builds made with --no-default-features have none of it compiled in.

Sound effects are not in this version. model="1.2" is read and its dialogue is spoken, but a story that expects footsteps will not get them.

Accessibility

The window publishes a real accessibility tree through AccessKit — VoiceOver on macOS, UI Automation on Windows, AT-SPI on Linux — so a screen reader sees named controls rather than painted pixels. On top of that:

  • Every command has a keyboard shortcut, and the shortcut table is the same data the help window is generated from, so the two cannot drift apart. Press F1 for the list.
  • Panes are named and described. The preview announces itself as "Formatted preview" and says how to read it; the editor explains what Fountain markup is.
  • A live region. When the tool does something — saves, exports, jumps to a scene, finds seven matches — it says so in the status bar, which is marked as a polite live region so a screen reader announces it without stealing focus.
  • A loud focus ring. Three pixels, in a colour that clears the contrast floor on both themes, so keyboard focus is never ambiguous.
  • Light and dark themes that follow the operating system, both built from a palette where every colour that carries meaning measures at least 4.5:1 against every surface it is drawn on. There are tests that assert this.
  • A high contrast mode (⇧⌘H) — pure black and white, every boundary drawn.
  • Text scaling from 70% to 300% (⌘+ / ⌘- / ⌘0).
  • Colour never carries meaning alone. A status message says what happened in words; the colour only agrees with it.
  • The HTML export carries all of this with it, including into other languages — see Output formats. It is the copy that goes to a producer or an actor, so it is the one that most needs to be readable.
  • Nothing interrupts you to say it saved. The status bar is a live region, so the copy kept of unsaved work is silent by design — see If the editor stops.

Language

Every word the window says is looked up in a language file rather than written into the program, so the editor can be translated by somebody who does not program. View → Language… picks the language, or follows the computer's own setting; the choice is remembered, and changing it redraws the whole window without a restart.

A language is one plain text file:

code   = "fr"
name   = "Français"          # named in its own language, for the picker
plural = "french"

menu.file = "Fichier"
status.saved = "{name} enregistré"
outline.empty.hint = "Une intitulé de scène commence par INT. ou EXT."

Copy assets/lang/en.toml — the reference file, with every key in it and comments explaining the format — translate the right-hand side of each line, and drop it into the languages folder, which the language window will open for you:

Platform Folder
macOS ~/Library/Application Support/openwrite/languages
Windows %APPDATA%\openwrite\languages
Linux ~/.config/openwrite/languages

It appears in the picker immediately. Nothing is rebuilt and nothing is installed.

Four things make this usable by a translator rather than only by a programmer:

  • You do not have to finish. English is compiled into the binary and any key a translation has not reached falls back to it, so a file is useful from its first line. Reload the language files re-reads the folder while the editor is running, so the loop is: change a line, press the button, read the window.
  • One bad line costs one line. A file that will not parse is not refused; the lines that do parse are used, and the language window lists what was wrong with the rest, by line number.
  • Placeholders are named, never positional. {name}, {n}, {query} — a translation may put them wherever its grammar wants them. {C} becomes or Ctrl+ on its own.
  • Counting is the language's business. A counted message gives .one and .other — or .few and .many, for the Slavic rules — and the file says which rule it follows.

Fountain markup is the one thing that does not translate: INT., EXT., the colon in MAYA: Forty-one. and /transition: are what the parser reads, and they stay as they are in every language. The lines where they appear are marked in the reference file.

Tests hold the files honest: every key the window asks for is in the English file, every line in the English file is asked for somewhere, and any language shipped in the binary matches English key for key and placeholder for placeholder.

Updates

The editor asks GitHub once, in the background, whether there is a newer release. If there is, a small window offers the download; if there is not, or the question could not be asked, nothing is said and nothing is in the way. It is asked after the window is already up, so a slow network delays no screenplay, and dismissing it dismisses it.

This is the one thing the program does over the network without being asked, so: it fetches one small JSON document from api.github.com, sends nothing about you beyond a User-Agent of openwrite, and records the check in the debug log.

Refusing it should not depend on knowing that an environment variable exists, so Keep checking for new versions is a switch in two places: the window that does the asking, and the language window, which is where you can find it again once the first one has been turned off. It is remembered between runs. Setting OPENWRITE_NO_UPDATE_CHECK stops the check whatever the switch says, and builds made without the update feature never ask at all.

The debug log

⇧⌘L shows what the program has been doing — files opened and their sizes, how long each repagination took, what a model server said and how long it took to say it, and any error that went past. It is a ring buffer in memory, always running, and it can be copied or saved from the window in one press.

The rule it is written to is worth stating plainly:

The log records what the program did, never what the writer wrote.

Screenplay text, character names, world notes, the prompts sent to a model and the answers that come back are all your work, and none of them go in — a log you might send to somebody to look at should not be carrying your unpublished script. What goes in is counts, sizes, durations, formats, addresses and error text: "2,048 characters of prompt, 61 characters of answer, 1,204 ms" says everything needed to debug a model that will not answer and gives away nothing. There is a test that asks a stub model server a question with a distinctive phrase in it and asserts the phrase is nowhere in the log.

Repagination happens on every keystroke, so those entries are hidden until you ask for them — which is exactly when a document has become slow.

Setting OPENWRITE_LOG to a path writes every entry to that file as it happens, which is the only way to see the last entries from a run that ended badly; a panic is written there too.

Keyboard shortcuts

is Ctrl on Windows and Linux.

⌘N / ⌘O / ⌘S New, open, save
⇧⌘S Save as
⌘E Export formatted script (text, HTML or Final Draft)
⇧⌘C Copy the formatted script to the clipboard
⌘F / ⌘G / ⇧⌘G Find, find next, find previous
⌘K Characters and world
⌘I Ideas from a local model
⇧⌘A Audio drama
⌘1 / ⌘2 / ⌘3 Focus the outline, the editor, the preview
F6 / ⇧F6 Next / previous pane
⌘] / ⌘[ Next / previous scene
⇧⌘O / ⇧⌘P Show or hide the outline / the preview
⇧⌘H High contrast on or off
Language (View → Language…)
Open, save and record an audio drama (Audio Drama menu)
⌘+ / ⌘- / ⌘0 Text size
F1 Shortcuts and markup
⇧⌘L Debug log

The HTML export is a printable, screen-reader-friendly document in its own right: scene headings are real headings, each scene is a labelled landmark, there is a skip link and a scene navigation list, and the print stylesheet sets US Letter with correct margins. Print it to PDF for a submission-ready script.

Inside the screenplay, j/k/n/p move between scenes and ? lists the keys. They are live only once the screenplay itself has focus — reach it with the skip link, by clicking into it, or from the contents list — because single letters bound to the whole page fire while somebody is dictating into speech recognition software.

The page also says which language it is in, and its landmarks are in that language rather than always in English. That is the interface language, or a Language: line on the title page if your screenplay has one: a French script should be read aloud in a French voice.

Output formats

⌘E exports; the format follows the extension you choose.

.txt Fixed-width, exactly the page geometry.
.html Printable and accessible; print to PDF for submission.
.fdx Final Draft XML, for a production workflow.

As a library

openwrite::parse reads Fountain and the one-line dialogue form together; openwrite::shorthand::expand turns the latter back into the former, which is what you want before handing a source string to anything else.

let doc = openwrite::parse(&source);
let opts = openwrite::layout::Options::default();
let pages = openwrite::layout::paginate(&doc, &opts);
print!("{}", openwrite::render::text::render(&pages, &opts, false));

layout::paginate returns the pages every renderer works from, so text, HTML and the on-screen preview can never disagree about where a page breaks.

The formatting engine, the parser, the .sct document, the story bible and the shorthand are all available without the window: --no-default-features builds them with no dependencies at all.

Downloads

Tagged releases carry a macOS build for Apple Silicon, a Windows build for x86-64, and a .deb for x86-64 Debian- and Ubuntu-based distros, on the releases page. On macOS it is a .app bundle that opens .sct and .fountain files from Finder, and audio drama .xml files through Open With — it does not make itself the handler for every XML file on the machine; on Windows it is a single executable; the .deb installs the binary to /usr/bin with sudo apt install ./screenplay-creation-tool-linux-x86_64.deb.

Neither the macOS nor the Windows download is notarised or signed with a paid certificate, so the first open needs a nudge: on macOS right-click the app and choose Open; on Windows choose More info then Run anyway at the SmartScreen prompt.

Building

cargo build --release
cargo test
cargo build --release --no-default-features              # the engine alone, no window
cargo build --release --no-default-features --features gui   # no network at all

The formatting engine has no dependencies at all — --no-default-features builds it, and --self-check, with nothing else linked in. The application adds eframe for the window and rfd for the native file dialogs.

Talking to a local model has no dependencies either: it is a few hundred lines of HTTP/1.1 and JSON over std::net, in src/ai/, rather than a client library. It is still its own Cargo feature (ai, on by default), because it is the one part of the program that opens a socket and a build that should not be able to is worth being able to make. The update check is a second feature (update) for the same reason; it shells out to curl rather than linking a TLS stack for one request a session.

The audio drama is a third (drama), and has no dependencies either, which is less obvious than it sounds. Its HTTPS is curl, as the update check's is; the story format is read by a small XML reader in src/drama/story.rs; and the pitch shifting, the tremble, the panning and the mixing in src/drama/audio.rs are arithmetic on 16-bit samples, which is why it asks ElevenLabs for PCM rather than the MP3 it sends by default. It is the one part of the program that costs money, so it is also the one most worth being able to compile out.

Releases are built by .github/workflows/release.yml, which runs the tests, has the binary it just built check itself over a sample screenplay (--self-check, since a window cannot be opened on a build runner), and verifies the executable links nothing that will not exist on a user's machine. Pushing a v* tag publishes; running the workflow by hand builds and uploads the artifacts without publishing anything.

Fountain support

Everything below is standard Fountain, with the two departures noted at the end.

Title pages, scene headings (including forced .headings and #1A# scene numbers), action, character cues with extensions, dialogue, parentheticals, lyrics, dual dialogue, transitions, centred text, page breaks, sections and synopses, notes, the boneyard, and *italic* **bold** _underline_ emphasis.

Sections and synopses are parsed and shown in the outline but never printed — they are notes to yourself, not part of the script.

The two departures

A speech can be written on one line, along with /transition:, as described above. This is the tool's own and is expanded back into the three-line Fountain form whenever a .fountain file is written, so a file leaving here is one any other reader agrees with. The .sct document keeps what you typed.

A title page needs a title-page-shaped first key. Fountain treats any Key: value first line as a title page; here that key has to be either an ordinary capitalised word (Title, Credit, Production) or one of the usual keys spelled in capitals (TITLE:). That is what lets MAYA: Forty-one. typed into an empty document be Maya speaking rather than a title page with a key called MAYA. Every real title page is unaffected.

Licence

GNU General Public License, version 3 or later. The full text is in LICENSE.

This program is free software: you can redistribute it and modify it under those terms. It comes with no warranty.

The bundled Ubuntu Bold typeface is a separate work under the Ubuntu Font Licence 1.0, in assets/fonts/.

About

An accessible screenplay editor and Fountain formatter, with an Audio Drama menu that records a story file in ElevenLabs voices — pitched to each character's age, treated to match how the line is said, and mixed into one stereo file. Rust, two dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages