Skip to main content
Everything a run generates lives under one directory. [project] build names it, and it is build/ unless the project says otherwise. Nothing under it is written by hand, and deleting the whole of it costs only the time to make it again. The paid records never live under build/. The voiced takes and the words their voice sent back live in the takes directory, takes/ unless [narration] takes_dir names another, which you commit, with a second copy in the machine’s take store, [narration] store_dir. The bought sounds and the ledger that lists them live in the score directory, score/ unless [score] dir names another, which you commit too. The take index, the placeholders, the joined narration and the joined music stay under build/, because a build makes them again for nothing. This page describes the engine as it is, and most of it is a private cache. What DeckTalk promises a caller is the stability promise: the --json results, the events file, build/final/ and the exit codes. A shape on this page can change in any release, and a script that reads a cache file reads something nobody promised it. Every file a stage writes a shape for is a frozen model, so the field names below are the JSON keys and the model both reads and writes the file. A file is written under a temporary name in the same directory and renamed over the target, which is atomic on every platform DeckTalk ships on, so a reader never opens a half-written artifact and an interrupted run leaves the previous file whole. A cache file that is there and will not parse, including one an older release wrote, is built again by the stage that writes it. A paid record that will not parse is refused with a sentence and left where it is, because building it again would buy it again. The take index is not a paid record, because it is read again off the takes on disk.

Finding an artifact without knowing a path

Every result whose command writes files declares written, which carries the project-relative path of each file that run wrote, in the order it wrote them. That is how a caller finds an artifact rather than by spelling a path.
The same rule holds everywhere: a path DeckTalk publishes is relative to the project root and spelled with forward slashes on all three platforms.

The tree

The takes a voice spoke and the sounds the score bought are not under build/. They sit in the takes directory and the score directory, which you commit:
Each sound is named by its item, so --replace-score with --spend writes over the same file. A take stays in the takes directory after the script moves on from it, such as after a voice change. decktalk status lists the takes there that no section plays, and the aligned words under aligned/ that time none of the takes a section plays, with their size, and --json carries them as unplayed. DeckTalk never deletes one, so remove the ones you no longer want with git rm. A take counts as played when the take index or a section’s current text, voice and settings name it, and its aligned words count as played whichever aligner measured them, because with the aligner down an older aligner’s words are what the take plays. With no voice named unplayed is null. The rejected/ folder is not listed, because each take in it was set aside on purpose, to be moved back under its digest or removed. See Voice one turn again. Everything else a run writes is under the build directory:
When [output] timestamped_copy asks for it, final/ also keeps <name>-<YYYYMMDD-HHMM>.mp4, a copy of the film named with the minute it was taken. <name> is [project] name, which is what the film is named after. NN is a section’s key, which is its number padded to two digits, so section 3 is 03. <run> is the twelve hex characters that name one run. Three of those are not artifacts in the sense of a file a later stage reads. frames/ and storyboard/ are pictures for a person and for a comparison. events/<run>.jsonl is what a run said while it ran, appended by a subscriber to the event stream rather than written by a stage.

build/narrate/takes.json

The take index is the time base every later stage measures against. It says which section plays which take, and the rows are enough to work out where each section sits in the joined narration, so the file cannot hold a total that disagrees with what it lists. It is a cache, not a paid record. Every field of a row is read again off the script, the settings and the take and words files its digest names, so narrate builds an index that will not parse again from the takes on disk, keeps every take it finds and buys nothing for it.
build/narrate/takes.json
A section runs for its lead, then each turn’s take up to where its sound ends followed by the gap before the next turn, then its tail after the last, and the sections run in section order. Where each one sits in the joined narration is therefore arithmetic over the rows rather than a second file that can disagree with them. The lead, the sound end and the gap or tail of each row are a pure function of the take’s own bytes and its own section’s settings and script, so a section lands the same way whether this run voiced its take or found it cached.

A take is named by a digest of its inputs

A take is identified by its input digest and by nothing else. The index maps a section to a piece of content and never the other way round, so renumbering a section rewrites one row and moves no file, and two turns with the same words and the same voice share one take. The digest of a voiced take is taken over exactly these, joined by newlines in this order. Every input but text refuses a newline before anything is sent, and text comes last, so the joined payload reads back as exactly one set of inputs and no two sets share a take. The take’s file is named by the digest under the suffix of the format it holds, which is .mp3 for every ElevenLabs format. This is why an edit voices only the sections whose words changed. Edit one paragraph of script.md and only that section’s text moves, so only that section’s digest moves, so narrate sends one request and every other section is found on disk under the name it already had. Change the voice id or the model and every digest moves, because every take really would sound different. Adding a field to that list or changing its order would re-voice every project there is, which is what the digest test in the repository holds it to. A placeholder take is named the same way over its own three inputs, which are the pace it is sized at, how long a declared pause is held, and the text it stands in for. Its digest carries the prefix placeholder-, so a run that writes placeholders can never overwrite a voiced take. A take is current when the current inputs name its digest. Each turn’s take state is read from that rule alone, and a section takes the worst state of its turns: voiced when a voiced take of the current inputs is held, unchecked when a voiced take of this text is held and no voice is named to match it, stale when the take index’s voiced take for the turn or its text is held under another digest, placeholder when only a placeholder of the current text is held, and missing when nothing the turn could play is held. status, check, narrate and build --watch read the same five states. stale lasts until a run that does not spend rewrites the take index to the placeholder it plays.

takes/<digest>.words.json

Beside every take sits its words file, under the same digest. This is the smallest artifact and the one every other reads: a cue resolves against it, the captions are built from it, and the clicks of a placeholder take sit at each start. The words the speech provider sends back with a voiced take are a paid record, because only voicing the take again gives them back. They are written in one step with the take, and they record the take’s size and BLAKE3 under audio, because a take’s name says what was asked for and only this fingerprint can tell a truncated or swapped take from a good one. audio also records the suffix the take was written under, so a take is still found after the voice moves to another format. A copy whose words do not read, or whose take does not match them, is skipped wherever it is found, moved aside as <name>.unreadable, or as <name>.<n>.unreadable with the first number free for both files when that name is taken, when a good copy replaces it, and never copied or deleted. When no copy anywhere is good, the run is refused with a sentence, because the next step would buy the take again. A words file that records no audio print was written before the print and can never be told good, so its take counts as not held: a run that may spend buys it again, as it buys a take no place holds, and moves the old pair aside when it writes the new one. Words DeckTalk estimated for a placeholder take are a cache under build/narrate/, like the rest of build/. A provider or an aligner can give a take’s last word to the take’s trailing silence, so the word runs on to the end of the file. The join cuts every take at its sound end, so every reader of a take’s words, from cue times and captions to the word lanes and decktalk words, reads them cut there: a word that ends past the sound end ends at it, and speech_end_seconds in the take index is where that cut last word ends. The words file itself keeps what its voice sent. A word that starts at or past the sound end is never heard, so it plays with no length at the sound end and narrate reports it as TAKE_WORD_CUT. A voiced take with no sound at all, whose sound end is 0, plays silence of its placed length, and the run that makes it reports it as TAKE_SILENT, naming the turn to buy again.
takes/1e039d03eff9853a.words.json
Each row is the same Word a result carries, so the file a stage writes and the JSON a caller reads are one shape. start and end are seconds after that take starts, which is not the same clock as the section: the section’s own clock begins at its lead.

build/cue-times.json

One block per section, each holding every cue that section declares, resolved against the words it speaks.
build/cue-times.json
The recorder passes these seconds to the page as the cues query, so a cue with no second behind it is left out of that value rather than passed as a null the page would have to reason about. A page previewed in a browser has no such URL, so the local origin answers one alias from this file instead.

build/recordings/

record writes two files per page section, both named by the section’s key, and a render section has two more of its own. NN.mp4 is the recording, one frame for each step of the section clock at video.fps, losslessly encoded because assemble encodes it once more at the film’s own quality. NN.json is the recorder’s whole account of that section, written when that section is finished, so a reader of a long run sees each section’s log as soon as that section is done and a measurement always belongs to the recording beside it. The sample below was recorded with record.concurrency = 4, the most recorders an automatic choice runs, so it shows the section split as a machine of twelve or more CPUs splits it. Its pieces and steps follow the number of recorders, and a machine that runs fewer writes fewer of each.
build/recordings/01.json
Every judgement the recorder makes is a finding, so the page’s own warnings, the exceptions it threw and the checks over the frames are one list a reader dispatches on by code. made_from is what decides whether a section is recorded again. Its parts are every turn’s words, voice and word times, the markup of the one scene the section plays, the rest of its page, which every scene shares, the content of every file the page loaded, its scene and its own query parameters, its resolved cues, its length, the frame geometry, the colour scheme, the page policy, the motion setting and the engine. A section none of whose parts moved would be recorded again for nothing, so it is kept, and a section whose parts moved is named by them. Rebuild one section lists every part. report is what the page answered window.__decktalkProbe.report() with, kept whole. Runtime gives every field of it. report.motions is the lane of every motion, loop and region the page declared, which is what verify masks and measures. A deck that declares none has an empty list, as the starter’s log above does.
build/recordings/01.json
The lane above is the packet of decktalk init --example motion, with seconds that show the shape rather than a real run.

A render section’s files

A render section is drawn by its render_command rather than recorded from a page, and it still leaves NN.mp4 and NN.json. Its recording.url is render: followed by the command, its report carries only motions, which are the landings its program wrote, and its made_from holds its turns’ words, voices and word times, the command, the content of every project file the command names, the motion document and the engine. So editing the program, a word of the narration or a cue’s second draws the section again. Beside them, under build/recordings/render/NN/, are the two files the program trades with record. A render section shows a program that writes both.

score/ledger.json

The score directory holds one audio file per sound the [score] table declares, named after the item unless the table sends it elsewhere. The ambience bed is ambience.mp3, each effect is named after the key it is declared under, and the music bed is bought in parts, music-part1.mp3 and on, which are joined into build/score/music.mp3. A run that finds the parts and not the joined piece joins them again, which buys nothing. Beside them is ledger.json, which is what this project has already bought.
score/ledger.json
Every request is paid for, so the one question this file answers is whether the audio on disk was made from the request the project asks for now. A row whose digest still matches is kept rather than bought again. It is one file for the whole score rather than a cache file beside every output, because the outputs may sit wherever decktalk.toml sends them and a run has to read the whole record before it prices anything. A ledger that is there and will not parse, including one an older release wrote in another shape, is refused with a sentence and left on disk rather than read as a project that has bought nothing, because moving it aside is a decision about money and so it is the author’s to take.

build/sections/

One section video per section, encoded to the span the take index gives it, which is what assemble concatenates into the film. A section video left behind by a renumbering belongs to no section, and assemble names each such file rather than folding it into a film nobody asked for. Beside each section video, NN.json holds the key it was encoded from.
build/sections/01.json
The digest is a sha256 over the whole argument list of the encode, the BLAKE3 digest of every file it read and the engine version. assemble keeps a section video whose key still matches rather than encoding it again, so an edit to one section encodes that section again and leaves the others alone. Nothing chooses which arguments count, because a key over a hand-picked subset would one day miss an input and ship a stale picture, and a new engine version encodes every section again because it may encode the same arguments differently.

build/stills/

Every frozen frame check, storyboard, the poster and the captioned copy’s captions draw, kept by what drew it, so one state of a page is drawn once whichever of them asks for it first. <key>.png is named by a digest of everything the caller knows before the page opens: the URL, the frame size, the colour scheme, the motion settings, the content of the page file and the engine version. <key>.json beside it names every project file the page went on to load, with its digest then, because a picture or a module the page reached for is part of the picture too. A still is used only when its key matches and every file its manifest names still has the digest it had, which is the same rule the recorder applies to a whole section. So a second check on an unchanged deck opens no page for a frame it already has, and an edit to one picture draws again only the frames that loaded it. The slate that stands in for a missing clip is kept here too. Its key is everything it shows: the chapter title, the lines above and below it, the frame size, [video] slate_color, the page policy and the engine version. A renamed chapter or a new colour therefore draws a new slate, and the section cut that reads it gets a new key. A frame nobody has found or kept for a fortnight is removed whenever another is kept, so the frames of an edited page and the slates of a renamed chapter do not stay for as long as the project does.

build/kept.json

The record a build leaves of its last assemble and verify, which is what lets the next build keep them. For each stage it holds the digest of everything the stage read with the options it ran with, the digest of every file it wrote, and every finding it made. A build whose digest for a stage matches, and whose outputs still hold those bytes, keeps that stage rather than running it. assemble and verify are pure functions of files already on disk, so the film on disk is the one they would make and the measurement is the one they would take. The stage is reported with the outcome kept, its findings are reported again, and --force runs it anyway. The digests are over file contents and never over file times, because a copy or a checkout moves every time and changes no byte. Only build writes this file, so after a verify run on its own, status still names verify. A record this version cannot read keeps nothing, which costs one assemble and one verify and is never wrong.

build/final/

Everything a viewer receives. <name> is the project name.

build/final/<name>.words.json

Every word the film says, in the order the words start, on the film’s own clock: the narration’s, spelled and punctuated as its captions show them, each with the voice and the turn that say it, and every word inside a clip section it captions. It is one object with a words list, in the shape of a clip’s words file, so a film played as a clip section of another film with this file beside it under the film’s own name, such as media/larkbill.words.json, has its speech captioned there. output.timestamped_copy writes a second copy of the film beside the first, named with the date and the minute the copy was taken.

build/final/placements.json

This is the one record of the shape of a film. The transcript page, a caption reader and anything that wants to jump to a section read it instead of adding section files up.
build/final/placements.json
substitute says plainly where a slate or a black frame stood in for something the project does not have. A Python call to assemble or build with strict=True refuses such a section outright, unless it is a clip whose section declares optional = true.

build/events/<run>.jsonl

Every run of a project appends one JSON object per line to its own file, named by its run id. One file per run, never truncated, so a watch loop running beside a build by hand cannot overwrite what the other is writing. --events prints the same lines on stderr as they happen.
build/events/12d3b2212fd2.jsonl
event is the discriminator. time, seq and run are on every line, and seq counts per run rather than per machine, so a reader can tell a gap from a lost line. The event stream gives every name and every field of every line. A directory of every run a project ever made would grow without limit, and the runs a caller wants are the recent ones, so events.keep_runs prunes the oldest. It defaults to 20 and the pruning happens as a run opens.
decktalk status --json reports every run whose events file is still open, which is how a caller finds a build running in the background and follows its lines.

build/frames/ and build/storyboard.html

check freezes each slide either side of a cue and compares the two pictures, which is how it says whether a reveal really changes anything before a single credit is spent. Those pictures are written to build/frames/<key>/<state>.png, where <state> names the URL that drew it, such as freeze-2.1-after-2.1_formula.png. Each state is drawn once however many cues are measured against it, because the same URL is the same picture, and each is copied from build/stills/ when a still of it is already kept there. storyboard writes its own stills under build/storyboard/<key>/ with the same naming. Both commands lay their panels out on one page, build/storyboard.html. It sits beside the stills rather than among them, so a reader opens one file and a rebuild replaces the whole sheet, and one writer draws it whichever command froze the frames, because two contact sheets of one deck would be two answers to one question. It is a plain page that needs nothing to open it, with one figure per panel captioned with its section, its slide, its moment and its second.

build/.lock

A run that writes holds an operating system lock on this file for its whole length, which is flock on Linux and macOS and a byte-range lock on Windows. The system releases it the moment the holder ends, however it ends, and two writers that race for it cannot both win. While it is held, build/.lock.owner beside it names the process id and the run id, so a second writer is refused with a sentence that names whose run is in the way. The owner is a note of its own because Windows refuses a read of a locked file. .lock itself stays on disk after the run, empty. A file removed while another writer waits on it would let a third lock a new file beside the old one, so deleting it is never needed and never helps. The holder removes .lock.owner when it ends, and a holder that crashed leaves it behind. A run that finds .lock.owner while nobody holds the lock takes the lock and says so, because a caller cannot clear a note it was never told about.