A command-line tool designed to encode media (videos, gifs, images, and audio tracks) into Factorio memory blueprints, allowing you to build massive animated screens and programmable speakers directly in-game. Designed with Factorio 2.0 and Draftsman.
Try it online: https://StarlightIbuki.github.io/factorio-displayer/
The web app lets you add media in the browser, edit a timeline (trim / crop /
audio, incl. MIDI), and generate a blueprint through the hosted API at
https://factorio.qvq.moe:60012 (sign in with GitHub, or use an access token).
The frontend defaults to a local backend when present and falls back to the
public one automatically.
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -e '.[web,audio]'
.\.venv\Scripts\python.exe -m factorio_display server --host 127.0.0.1 --port 8000Open http://127.0.0.1:8000/ — the frontend auto-detects the local backend.
See docs/deploy.md for the full deployment guide (GitHub Pages frontend +
HTTPS high-port API, GitHub OAuth, access tokens).
Factorio Displayer runs via a CLI command that generates raw blueprint strings, which you can pipe straight into your clipboard (e.g., | Set-Clipboard on Windows or | pbcopy on macOS).
Generates the base blueprint of your physical video display containing wired lamps.
factorio-display export-display --width 28 --height 28Generates the blueprint unit used to demultiplex and interpret the memory audio signal for programmable speakers.
factorio-display export-audio --instrument pianoSupported instruments: piano, bass, celesta, plucked, drum.
Converts a video file, GIF, or image sequence into Decider Combinator "Memory" arrays. MIDI files (.mid) are auto-detected and routed to the audio pipeline.
# Video
factorio-display encode ./bad_apple.mp4 --name "Bad Apple Frame Data" --fps 30
# Video with sound (audio track is extracted and emitted as separate pieces)
factorio-display encode ./clip.mp4 --name "Clip + Sound"
# Audio (auto-detected from extension)
factorio-display encode ./song.mid --ticks-per-beat 30Tip: Adaptive frame-dropping is on by default with a conservative threshold (--no-adaptive to disable, --threshold 0.005 to tune). Add --deduplicate to recycle identical frames.
By default encode emits independently-wireable pieces instead of one giant merged blueprint (which for a long video at large width is hundreds of MB and minutes to build). Video, audio, and video + sound are all supported this way:
factorio-display encode ./big.mp4 --width 70 --time-chunks 3 --output-dir out- Video →
out/display.txt(the lamp display, with a per-chunk connector CC on each red data bus) +out/memory_c{chunk}_f{frag}.txt(one memory piece per vertical chunk × time fragment). Each memory bank grows vertically (fixed-width rows, horizontal = the width direction) and carries a top connector CC + an isolated top series-marker CC + a bottom connector CC, all right-aligned. The time axis is auto-split so every memory piece's serialised blueprint stays around ~2 MB (tune with--max-piece-mb); pass--time-chunks Nfor an explicit uniform split instead. - Audio / video soundtrack →
out/player.txt(the decoder, with a bottom-edge connector CC per rail) +out/memory_r{rail}.txt(one memory piece per rail, connector CCs on both ends). - Unified bus colours: the time/clock bus is GREEN and the data bus is RED (video and audio alike). Connector CCs join both buses so a single wire pair links neighbouring pieces. Each connector carries the matching identifying signal at value 1 with the CC "Output" toggle OFF (
is_on=false) — the signal is visible on the map as a label but is never emitted onto either bus. An isolatedsignal-infoCC notes the chunk/rail series number (1-based, also visible).
Pieces are built and materialised in parallel worker processes, so generation scales with the number of cores.
Blueprint book — opt-in (--book). The book pass re-parses every piece
and re-serialises the combined book (a few seconds of extra work), so it is
off by default for performance; pass --book to also assemble
out/book.txt for a single import, or --no-book to force individual
pieces.
To assemble in game:
- Import the book (if generated), or each piece individually.
- Place each piece next to its neighbours and wire the matching connector CCs (the ones carrying the same signal) — red wire joins the data bus, green wire joins the time/clock bus.
- Join every piece's clock input to the shared clock.
The legacy single-blueprint composition (timer + power poles + everything merged into one big blueprint) is still available via --all-in-one, but is not recommended — the composer-based layout/wiring does not scale reliably, so it should only be used for small outputs.
MIDI files are auto-detected by encode (see step 3) — there is no separate
encode-audio subcommand. Pass the translation options directly:
factorio-display encode ./song.mid \
--ticks-per-beat 30 \
--boost-melody 1.5 \
--attack-ticks 10 --decay-ticks 10 --sustain-level 0.8 --release-ticks 10 \
-o song_audio.txt| Option | Default | Description |
|---|---|---|
--ticks-per-beat |
30 |
Game ticks per quarter note (30 = real-time at any tempo) |
--boost-melody |
1.0 |
Velocity multiplier for the melody track (1.5 = 50% boost) |
--velocity-scale |
1.0 |
Global loudness multiplier |
--attack-ticks |
10 |
ADSR attack ramp duration in game ticks (70%→100%, 0 = off) |
--decay-ticks |
10 |
ADSR decay ramp duration in game ticks (100%→sustain, 0 = off) |
--sustain-level |
1.0 |
ADSR sustain level 0.0–1.0 (default 1.0 = no decay) |
--release-ticks |
10 |
ADSR release ramp duration in game ticks (sustain→0%, 0 = off) |
--attack-curve |
1.0 |
ADSR attack power-curve exponent (>1=gentle start, <1=snappy, 1=linear) |
--decay-curve |
1.0 |
ADSR decay power-curve exponent (>1=gentle, <1=snappy, 1=linear) |
--release-curve |
1.0 |
ADSR release power-curve exponent (>1=gentle, <1=snappy, 1=linear) |
--rearticulation-ticks |
2 |
Re-attack same-pitch notes re-triggering within this many ticks of the previous note's end |
--no-global-shift |
— | Disable optimal global octave shift; use only per-note folding |
--rail-mode |
auto:0.05 |
Multi-rail mode: piano, all, auto[:threshold], or comma-separated instruments |
--map-drums |
(off) | Route below-range low notes to a kick drum instead of covering them with the bass instrument |
--drum-gain |
0.25 |
Volume scale for the drum rail (0–1; drums sit low in the mix) |
--no-attach-player |
— | Output audio memory pages only, without the player decoder blueprint |
--drums |
(off) | Detect kick/snare/hat hits from raw audio (non-MIDI) and add a drum rail (auto/off) |
--instruments |
— | Deprecated alias for --rail-mode |
--debug-json |
— | Dump raw tick_data as JSON for inspection |
--processed-midi |
— | Save octave-folded MIDI for preview in any player |
-o, --output |
— | Write blueprint to file instead of stdout |
The encoder applies a per-note ADSR (Attack-Decay-Sustain-Release) envelope to shape each note's loudness over time. All durations are in game ticks (60 ticks = 1 second at UPS 60).
loudness
^
| /\
| / \______
| / \
| / \
| / \
+----------------------> time (ticks)
A D S R
| Phase | Range | Behavior |
|---|---|---|
| Attack | 0 → attack_ticks | Ramps from 70% to 100% of peak loudness |
| Decay | attack → attack+decay | Ramps from 100% down to sustain_level |
| Sustain | attack+decay → duration−release | Holds at sustain_level × peak |
| Release | duration−release → duration | Ramps from sustain_level down to 0% |
Each phase supports power-curve shaping via --attack-curve, --decay-curve, and --release-curve:
> 1.0— gentle start, fast finish (convex, sounds "plucked")= 1.0— linear ramp (default)< 1.0— fast start, gentle finish (concave, sounds "bowed")
If the note is shorter than attack+decay+release, phases are shortened proportionally. Set all ADSR options to 0 and --sustain-level 1.0 to disable the envelope entirely.
factorio-display supports an intermediate representation called the Logical Blueprint — a TOML-based format that describes what entities and circuit networks exist without committing to physical positions or explicit pairwise wiring.
This format is designed to be LLM-friendly: easy for language models to parse, generate, and modify.
- Entity: A combinator or speaker with an
id,type, and type-specific properties.positionanddirectionare optional. - Network: A named virtual circuit network (
redorgreen). Entities join a network via their endpoints ("entity_id:port"). When two endpoints are connected, their networks are merged (union-find semantics). Red, green, and power networks are always kept separate. - Endpoint: A specific connection point on an entity —
"input"(left side, where operands/conditions are read) or"output"(right side, where results are emitted).
- Generate a
LogicalBlueprint(entities + networks, no positions). - Layout — assign tile positions and expand each
[[network]]into short pairwise wires. - Materialise — convert to a draftsman
Blueprintfor final Factorio export.
label = "Audio Decoder"
[[entity]]
id = "mod"
type = "arithmetic-combinator"
first_operand = "signal-clock"
operation = "%"
second_operand = 60
output_signal = "signal-M"
[[entity]]
id = "ch0_lut"
type = "constant-combinator"
[[entity.signal]]
name = "iron-chest"
quality = "normal"
value = 60
[[entity]]
id = "ch0_match"
type = "decider-combinator"
[[entity.condition]]
first = "signal-each"
op = "="
second_signal = "signal-M"
[[entity.output]]
signal = "signal-each"
copy_count = false
constant = 1
[[entity]]
id = "spk_0"
type = "programmable-speaker"
instrument = "piano"
note = "F3"
vol_signal = "signal-F"
vol_quality = "normal"
polyphony = true
[[network]]
id = "red_0"
color = "red"
endpoints = ["mod:output", "ch0_match:input"]
[[network]]
id = "green_0"
color = "green"
endpoints = ["ch0_lut:output", "ch0_match:input"]# Export the audio decoder as a logical blueprint (TOML)
factorio-display export-logical --instrument piano --name "My Decoder"
# Export via the existing export-audio command
factorio-display export-audio --instrument piano --format logical
# Dump a MIDI encode's intermediate logical blueprints as TOML
factorio-display encode song.mid --debug-toml out/
# Convert blueprint string text to logical YAML
factorio-display blueprint-to-yaml blueprint.txt -o blueprint.yamlfrom factorio_display.logical_blueprint import (
LogicalBlueprint, LogicalEntity, Endpoint, to_toml, from_toml,
from_draftsman, from_blueprint_string, to_draftsman,
to_yaml, blueprint_string_to_yaml,
)
# Build programmatically
lb = LogicalBlueprint(label="My Blueprint")
lb.add_entity(LogicalEntity("mod", "arithmetic-combinator", properties={
"first_operand": "signal-clock", "operation": "%",
"second_operand": 60, "output_signal": "signal-M",
}))
lb.add_entity(LogicalEntity("dc", "decider-combinator", properties={
"conditions": [{"first": "signal-each", "op": "=", "second_signal": "signal-M"}],
"outputs": [{"signal": "signal-each", "copy_count": False, "constant": 1}],
}))
lb.connect("red", Endpoint("mod", "output"), Endpoint("dc", "input"))
# Serialize to TOML
print(to_toml(lb))
# Parse from TOML
lb2 = from_toml(toml_string)
# Convert to/from draftsman Blueprint
bp = to_draftsman(lb)
lb3 = from_draftsman(bp)
lb4 = from_blueprint_string(bp.to_string())
# Serialize to YAML
print(to_yaml(lb))
# Convert blueprint string directly to logical YAML
print(blueprint_string_to_yaml(bp.to_string()))This project maps complex arrays of binary data (pixels, sound pitches, and timing) into Factorio's circuit network by exploiting the new mechanics introduced in Factorio 2.0 Space Age.
In Factorio, a color display must process an X/Y grid of RGB pixels.
-
Signal Multiplexing: We don't have enough colored wires to assign one per pixel. Instead, each physical pixel on the display is statically assigned a unique Factorio signal identity (e.g., "iron-plate" at "legendary" quality). This mapping is precomputed and distributed.
-
The Display Screen: Thousands of small lamps form the screen. Every single lamp is permanently wired to the same network but is configured to only read the specific signal/quality pair assigned to its coordinates.
-
The Memory Banks: We use an array of Decider Combinators acting as Read-Only Memory (ROM). Each combinator represents a single frame of video. The condition of the Decider Combinator checks if the global signal-clock matches its designated frame number.
-
Playback: A global clock counts upward (+1 per tick). When the clock matches a frame's assigned time index, that specific Decider Combinator activates and dumps a massive payload of signals onto the red wire. The lamps instantly decode their specific signals from this payload and light up with the correct RGB value, effectively refreshing the screen 60 times a second.
The audio decoder drives a 48-speaker matrix (12 semitones × 4 octaves, F3–E7) using a compact combinator layout with zero wasted tile rows. Each speaker is mapped to a unique (signal_name, quality) pair — natural notes use letter signals (F→signal-F), sharps offset by +10 (F#→signal-P), and Space Age quality tiers encode octave.
Decoder pipeline (top → bottom):
- Modulo AC:
clock % 60 → signal-M— produces a sub-tick index (0–59) that cycles every 60 ticks. - Lookup CCs: 12 constant combinators store the packed audio data for all 720 cells of the current page, keyed by sub-tick.
- Match DCs:
each == signal-M → signal=1— the cell whose signal matches the current sub-tick outputs 1. Sub-tick 0 of each page plays nothing (its CC value would be 0, which Factorio drops from the network), so each page's first tick is silent by design. - Selector ACs:
each(red) × each(green) → bell— multiplies the memory page data (red wire) by the match output (green wire), isolating the packed integer for the current sub-tick onto thebellbus. - Unpacker AC chain (6 per channel): Extracts the four 7-bit loudness values from the packed
bellsignal via bit-shifts and masks:l1 = bell >> 21s2 = bell >> 14→l2 = s2 & 127s3 = bell >> 7→l3 = s3 & 127l4 = bell & 127
- Speakers: 48 programmable speakers (4 rows × 12 columns), each listening on its assigned
(signal, quality)pair withallow_polyphony=True.
Total entities: 48 spk + 85 AC + 12 DC + 13 CC = 158.
Wire colors: RED = page data bus + sub_tick distribution, GREEN = CC lookup outputs + bell bus. In piecewise output the time/clock bus is carried on GREEN (the mod AC outputs it directly — no red→green relay combinator) and the data bus on RED (unified for video and audio), and each connector CC joins both so pieces wire up with red data + green time. Connector CCs carry their identifying signal at value 1 with the "Output" toggle off — visible on the map, never emitted onto either bus.
Black MIDIs and dense orchestrations can produce summed loudness values far exceeding the 0–100 range used by the 7-bit packing scheme. By default, the encoder applies global peak normalization: after all notes are mixed, the entire tick dataset is linearly scaled so the loudest peak hits exactly 100. This preserves relative dynamics (a note 2× louder stays 2× louder) instead of hard-clipping everything above 100 to the same ceiling.
MIDI files often use note ranges outside the 4-octave F3–E7 speaker matrix. The encoder handles this with two complementary strategies:
-
Optimal global octave shift (default, on): Before translating notes, the encoder scans all pitches across the entire MIDI file and finds the octave shift (multiple of ±12 semitones) that brings the largest number of unique notes into range. This is applied as a single bulk transposition — e.g., shifting a C4–C6 piece down by one octave to fit perfectly. Use
--no-global-shiftto disable. -
Per-note octave folding (always on): Any note still outside range after the global shift is individually folded up/down by octaves until it fits. The encoder logs each folded note to stderr.
Together, these strategies ensure maximum note fidelity: the global shift aligns the sonic register with the speaker matrix, and per-note folding catches the remaining outliers.
Planned work is tracked in docs/roadmap.md. The former
readme roadmap items (all-in-one blueprint, blueprint-book output, more
instruments / 5-octave support, display power layout) are all implemented or
superseded: piecewise chunked output is the default, small outputs are packed
into a book automatically, the instrument family now spreads across F2–E9,
and the display ships without power poles (the player places power in-game).