[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 declareswritten, 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 tree
The takes a voice spoke and the sounds the score bought are not underbuild/. They sit in the
takes directory and the score directory, which you commit:
--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:
[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
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
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 itsrender_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
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.
Related
- What a caller may rely on: The stability promise
- Read these files from Python: Python API
- Run the stages that write them: CLI
- Move the take store or prune the event files: Configuration
- Look up what the page reports into a recording log: Runtime