data_dungeon
Version, currently 0.11.137 versions
- 0.16.7latestOct 29, 2026
- 0.16.6Oct 22, 2026
- 0.16.5not indexedOct 25, 2026
- 0.16.4not indexedOct 25, 2026
- 0.16.3not indexedOct 25, 2026
- 0.16.2not indexedOct 25, 2026
- 0.16.1not indexedOct 25, 2026
- 0.16.0not indexedOct 25, 2026
- 0.15.7not indexedOct 25, 2026
- 0.15.6not indexedOct 25, 2026
- 0.15.5not indexedOct 25, 2026
- 0.15.4not indexedOct 25, 2026
- 0.15.3not indexedOct 25, 2026
- 0.15.2not indexedOct 25, 2026
- 0.15.1not indexedOct 25, 2026
- 0.15.0not indexedOct 25, 2026
- 0.14.0not indexedOct 25, 2026
- 0.13.1not indexedOct 25, 2026
- 0.13.0not indexedOct 25, 2026
- 0.12.2not indexedOct 25, 2026
- 0.12.1not indexedOct 25, 2026
- 0.12.0not indexedOct 25, 2026
- 0.11.2not indexedOct 25, 2026
- 0.11.1not indexedOct 25, 2026
- 0.11.0not indexedOct 25, 2026
- 0.10.0not indexedOct 25, 2026
- 0.9.0not indexedOct 25, 2026
- 0.8.0not indexedOct 25, 2026
- 0.7.0not indexedOct 25, 2026
- 0.3.5not indexedOct 25, 2026
- 0.3.4not indexedOct 25, 2026
- 0.3.3not indexedOct 25, 2026
- 0.3.2not indexedOct 25, 2026
- 0.3.1not indexedOct 25, 2026
- 0.3.0not indexedOct 25, 2026
- 0.2.0not indexedOct 25, 2026
- 0.1.0not indexedOct 25, 2026
github.com/trans/datadungeon
DataDungeon, cached/dedup assets manager
Nothing has been indexed for 0.11.1 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.
Installation
# Add this to your shard.yml
dependencies:
data_dungeon:
github: trans/datadungeon
version: ~> 0.11.1Then run:
shards installshard.yml
No shard.yml has been indexed for 0.11.1. You can read it on the repository.
Dependencies
Unknown: the shard.yml for this version has not been read yet.
README
This README is the one indexed from the repository at its latest ref, not from the tag for this version.
DataDungeon
DataDungeon stores and serves typed assets. It records searchable tags and
media-specific metadata, applies image storage policy (PNG or TIFF to WebP,
and tiled TIFF masters), and resolves stored objects through a Storage
adapter.
Asset identity
Every asset type is registered before anything of it is stored
(register_type), naming the traits that identify it, most important first.
Traits are ordinary tags. DataDungeon derives the asset's key from them:
the asset type, then the trait values in that order, joined by dots.
plug.golden.pg.f.human.tan-skin.red-hair
The key is an index, not a name: it finds the assets whose traits are these,
and any number can share it (takes of one voice line, say). Every store adds
an asset; what a pool is (which traits an application asks with) is the
application's call. The asset's id, and its bytes' SHA-256, are what name
one asset durably, never the key. The leading group_traits form the type's
group: the wider set of candidates that exact checks and ranking work
within. Because a trait's position says which trait it is, a lookup on
leading traits is a prefix match (plug.golden.pg.f.), one index range
scan, at any depth.
A type's name says what the asset is, from DataDungeon's list
(DB::STANDARD_TYPES, served by GET /vocabulary), so another app can
tell what it is: drop (a place, the set things stand in front of),
plug, placard, scrim, prop, texture, icon, pass,
automaton; speech,
dialogue, music, ambience, effect, recording; prose,
script, transcript, log; document, data; set, figure,
motion; sequence, font (see DESIGN-types.md). An app tells its own
kinds of one type apart with the shared tag subtype, its values the app's
own words (room, door-view), and registers each type it uses with the
traits its lookups need, subtype among them where it looks up by kind.
Since 0.16, register_type, rename_type and storing take only these
names, and anything else raises. A store an older DataDungeon left with
other names still opens and reads, and rename_type moves its assets onto
a listed type.
Keys are canonical: every trait is present, in the registered order, with an
empty segment for a trait the asset lacks (plug.golden.pg..human). Values are
normalized for matching (Unicode case folding and NFC; letters and digits
kept, apostrophes dropped, and every other run of punctuation, symbols or
spaces turned into -, so Zork III: The Dungeon Master becomes
zork-iii-the-dungeon-master), and the stored trait tags are normalized the same way, so a
value never contains a dot. labels add a suffix to traits whose values alone
would read ambiguously, such as several colours (tan-skin, red-hair).
Normalization can't make different words equal, so the application must spell
each value consistently.
sets names traits whose value is a set, such as a poster's colours. The
application passes the members comma-separated, in any order; DataDungeon
normalizes each, drops duplicates and sorts them, so "Red, blue, red" is
stored and keyed as blue,red. A set matches only the same set. Normalizing
turns commas inside a value into -, so a key's only commas separate set
members, and prefixes still stop at dots. Set traits can't be labelled.
db.register_type("prop", pool_traits: %w[thing colors], sets: %w[colors])
db.store("prop", {"thing" => "poster", "colors" => "Red, blue"}, ...)
# key: prop.poster.blue,red
A tag may be marked custom when its value isn't one of the application's
standard choices. Custom values are keyed like any other; custom_values
counts them and promote turns one into a standard choice without changing
any key.
db = DataDungeon::DB.new("assets.db", cache_root: "asset-files")
db.register_type("plug",
pool_traits: %w[art-style rating gender race skin hair-color],
group_traits: %w[art-style rating gender],
labels: {"skin" => "skin", "hair-color" => "hair"})
traits = {"art-style" => "silver", "rating" => "pg", "gender" => "f",
"race" => "ape", "skin" => "purple", "hair-color" => "red"}
record = db.store("plug", traits, custom: %w[race],
source_path: "portrait.png",
content_type: "image/png",
description: "A purple ape with red hair, facing left.",
generated: true,
generator: "runware:flux-dev-v1",
recipe: DataDungeon::Recipe.new("picture", settings: {"model" => "flux-dev", "size" => "512x512"},
prompt: image_prompt),
image: DataDungeon::ImageMeta.new(compression: "normalize", target_format: "webp")
)
record.key # "plug.silver.pg.f.ape.purple-skin.red-hair"
db.find_pool("plug", traits) # every trait equal
db.find_pool("plug", traits, custom: %w[race]) # every trait equal but race; rank race yourself
db.find_group("plug", traits) # the group traits equal
db.find_group("plug", traits, 4) # the first four traits equal
db.find_pool(record.key.not_nil!) # by the key alone
db.find_group("plug.silver.pg.") # by a key prefix alone
found = db.find("plug", traits, custom: %w[race])
found.pool # as find_pool with custom:
found.group # the rest of the group
db.custom_values("plug", "race") # {"ape" => 1}
db.promote("plug", "race", "ape")
db.get_by_id(record.id)
db.get_with_meta_by_id(record.id)
db.path_for(record)
A key already in hand is enough: find_pool(key), get(key) and
get_with_meta(key) take one, and find_group(prefix) a key prefix ending in
a dot (group_key_for gives one). These, and the trait forms without
custom:, are single index lookups. Asset type and trait names are lowercase
letters and digits joined by - or _ (a trait name starts with a letter),
so they never contain a dot. With custom:, the traits before the first
custom one narrow the candidates by prefix and the rest are checked against
each candidate's tags.
get, get_by_id and get_with_meta count a use of what they return
(use_count, last_used_at; pass touch: false for a check that isn't a
use). Uses are counted in memory and written a hundred at a time, if the
write lock is free, and the rest at close, when the process exits
(including a Kemal server's shutdown on SIGINT or SIGTERM), or by
flush_usage, so a read never waits on the disk or on another process's
write. Until then a
record shows the counts as last written, and a process killed outright
loses those not yet written: they're a hint for eviction, not a record of plays.
rename_tag(type, from, to) renames a tag on all of a type's assets and in
its registration; keys hold values, not names, so they normally stay the same.
rename_type(from, to) moves a type's assets and registration to a new name,
re-deriving their keys; if to already has assets, they join it under its
registration. Once nothing of from is left it changes nothing, so an app
can keep the call in its start-up until every copy of its store has moved.
The registration is stored in the database. Registering a type again with
the same definition changes nothing, and so does changing only what keys
don't hold (what's embedded, facets, scales, colors, cycles, weights, match
fields) beyond saving it. Other traits, order, group, labels or sets raise unless given
rekey: true, which re-derives the keys of its assets from their tags.
A store shared by several applications should register its types in one
place.
Takes, superseding and retiring
get(asset_type, traits) and get_with_meta(asset_type, traits) return the
preferred current asset with those traits: the highest rating (none counts
as 0), then the newest by made_at, when the content was made, which a
batch or an import passes (store(…, made_at: time)) and which defaults to
when it was filed. A flagged asset is out of play: get and best matches
skip it, while listings still show it for review. update_asset(id, rating:, flagged:) sets both; an application keeping people's reactions writes their
total into rating and a report into flagged. find_pool returns every
current one.
Naming the take a new one replaces keeps the history tidy:
store(…, supersedes: old.id) (or supersede(new_id, old_id)) retires the
old take, links new supersedes old, and copies the old take's other links
to the new one, data and all. A retired asset is kept, but leaves get,
listings, counts, best matches and search; it's still there by id.
retire(id) retires an asset with nothing replacing it, a soft delete, and
reinstate(id) makes one current again (if a take superseded it, it
supersedes that take in turn). Nothing retires anything by itself, and
nothing is deleted until the application prunes:
prune(asset_type, keep_last: 2, retired_before: time) deletes a type's
retired assets, keeping each key's last few or the recent ones.
old = db.get("speech", line)
take = db.store("speech", line, bytes: audio, content_type: "audio/mpeg", supersedes: old.try(&.id))
db.reinstate(old.not_nil!.id) # the old take back
db.prune("speech", keep_last: 3)
An asset is its bytes, so storing bytes its type already holds raises
AlreadyStored (with the holder's asset_id): change that asset's traits
with update_asset, or bring a retired one back with reinstate. Storing
them again with the same traits, as a retry does, just returns the asset.
Indexes per consumer
A type's own index serves the order its owner registered. An application asking in another order keeps its own index: it says who it is when it opens the store, and registers an index with a name and the traits it usually asks by, most selective first.
db = DataDungeon::DB.new(path, consumer: "cattacula")
db.register_index("speech", "lines", traits: %w[bit beat voice fmt])
db.list_assets("speech", {"bit" => "b1", "beat" => "0"}) # one range of that index
Applications just ask with traits. A lookup by tags counts where it could
start, capped so counting is cheap: the range of each index (the type's own,
or any consumer's) whose leading traits the query fixes, and each tag's
rows. It starts from the smallest and checks the other tags on each asset
there. Each asset has a key in every index of its type, kept current on
every write. A consumer changes only its own indexes: registering one again
changes nothing, other traits raise unless rekey: true, and
unregister_index drops one. indexes(type) lists every consumer's.
Every key is in one table, the type's own among them, so an application
never builds one: it registers the types it stores, looks assets up by
traits, and refers to one asset by its id. Before 0.12 a key was a column
of the assets table (pool_key:). 0.15 no longer converts a store from
before 0.12: open it read-write once with 0.14 first, which does.
description is the asset in words, what a picture shows or a clip says,
for people and for search. It's the asset's own and not its prompt: what a
generator was sent is the recipe's (below). generated records whether the
asset was generated, generator what made it, and its recipe how. An asset
added by hand (POST /assets) isn't generated and has no recipe; its
description is whatever words it's given.
DB#store stores objects by content: each object's storage key is the SHA-256
of its bytes under a directory named for the first two hex characters, with an
extension from its content type (ab/ab12….webp). Identical bytes are stored
once and shared by every asset that holds them (of different types), and
deleting or pruning an asset unlinks its object only when no other asset
still uses it. Deleting an asset also clears any clip's transcript_id
that names it (0.15.5). transcript_id is deprecated: link a transcript
transcript-of its recording instead (DESIGN-types). An asset's media comes from its content type unless given: its
top-level type (image, audio, video, text, model, font), JSON, XML and YAML
counting as text, and anything else (a PDF, raw bytes) data.
Existing rows keep the keys they were stored under. DB#store_asset
indexes a file whose path the caller already owns. Existing SQLite asset tables
are upgraded when DataDungeon opens the database.
To move such files into the store, DB#outside_store lists the rows recorded
by an absolute path, and DB#adopt(record) copies one row's file to its
content-addressed key and points the row at it, keeping its id, tags and
metadata. The original file stays where it is, for the application to remove.
db.outside_store.each do |old|
moved = db.adopt(old) # old.file_path → moved.file_path (ab/ab12….webp)
rescue File::NotFoundError
# the file is gone; the row is left as it is
end
Recipes
A recipe says how an asset was made, in one shape every app shares, so an
asset one app made can be read by another (DESIGN-recipes.md): its form
(picture, speech, or text from a chat model), the settings it was
made with, what its provider was sent, the named wordings that went into
that, and what's its own: seed, cost, when, and the files it was made from,
by hash.
rating = db.rating_text("pg") # "Suitable for older children: …", named dd:pg@<version>
rules = DataDungeon::Recipe.wording("rules", "overlore:house", "No text unless the description asks for it.")
style = DataDungeon::Recipe.wording("style", "overlore:silver-comic", "Silver Age comic: bold black ink outlines.")
prompt = [rating.text, rules.text, "Full body, head to toe.", style.text, "A tall elf with red braids."].join(" ")
recipe = DataDungeon::Recipe.new("picture",
settings: {"camera" => "mj:camera:flare", "model" => "flux-dev", "steps" => 4},
prompt: prompt, wordings: [rating, rules, style],
seed: 7, cost: 0.04, cost_basis: "reported: runware", generated_at: Time.utc,
references: [portrait.file_hash.not_nil!])
db.store("plug", tags, bytes: png, content_type: "image/png", recipe: recipe)
narrator = DataDungeon::Recipe.wording("style", "owl:narrator", "Warm, unhurried, a storyteller by a fire.")
spoken = DataDungeon::Recipe.new("speech", settings: {"model" => "gpt-4o-mini-tts", "voice" => "fable"},
prompt: "Once, there was a castle.", instructions: narrator.text, wordings: [narrator])
db.recipe(id) # => Recipe?, wordings read back as "overlore:silver-comic@3f9a2c71b0"
db.with_text("style", "overlore:silver-comic") # the assets using any of its wordings;
# "…@3f9a2c71b0" for one wording
db.with_text("rating", "dd:pg") # everything given DataDungeon's words for PG
db.list_assets(texts: {"style" => "overlore:silver-comic"}) # the same as a listing filter
db.named_texts("style", "overlore") # [{name, version, text}]
db.set_recipe(id, recipe) # give an asset made before 0.13 its recipe
db.add_wordings(id, [rating]) # name words a stored recipe was sent but didn't name
db.vocabulary # the shared tags (rating, subtype) and their values
DataDungeon::DB::STANDARD_TYPES # DataDungeon's asset types: name, title, media, what it is
- What was sent:
promptis the text the provider got: a picture's prompt, the words a voice speaks, a chat model's input.instructionsis the provider's second input beside it, a voice's or a chat model's instructions; a picture has none. The app writes them, and DataDungeon keeps them word for word. A step without a prompt (a matte, a depth map) has the form"", and an asset made in stepsRecipe.in_steps([edit, matte]). - Wordings are the words an app reuses, kept once in the store: each has
a kind (
style,rating,rules,framing,character,theme, …), a name,owner:name, the owner being the app (DB.new(…, consumer:)) orddfor DataDungeon's own, and its words. A recipe lists the wordings it used, and the store refuses one whose words aren't in its prompt or instructions word for word, before anything is written. Only the owner adds a wording under its name; any app uses the wordings as they are. - A version is the words' hash, the first 10 hex characters of their
SHA-256, so
overlore:silver-comic@3f9a2c71b0is the same words in every store. A wording is never changed or deleted (the store refuses it, in the sqlite3 shell too): new words are a new version, a rename a new name. - No purposes (Thomas, 2026-10-17): 0.13 and 0.14 let a name add one,
owner:name/purpose, so one style could be worded once for figures and once for places. That difference is framing, which has its own part, so 0.15 removed it: a name with a/is refused. The first read-write open by 0.15 drops a stored wording with a purpose that nothing names any more, and makes one still named a version of its plain name (overlore:silver-comic/place@3f9a2c71b0becomesoverlore:silver-comic@3f9a2c71b0, the same words), refusing to open if two would become one. - There are no standard styles (Thomas, 2026-10-17): each app names,
words and tags its own (
overlore:silver-comic, kindstyle). Styles shared between apps will be the hub's to coordinate, later: a lookup of what each store uses, so one app can adopt another's style and share. - A rating's words are DataDungeon's too, one wording for every form,
saying what's suitable rather than listing what's forbidden:
db.rating_text("pg"). A picture takes them with its rules, a chat text with its instructions; speech needs none, since its rating applies when its text is written. - A prompt's parts, in the order we recommend (advice, not a schema:
each prompt is the app's): the rules (the fixed ones, the same for every
picture, such as the rating's), framing (how much is in frame), the style,
theme (mood included), subject, description, staging
(where each figure is, and the view), light (the standing light, from the
place), and last the directives (this picture's own: its kind's rules,
and what an edit or a shot from a reference changes and keeps), where the
end holds what the picture is for.
ViewWords(below) puts a view or a light in words. - Cost is in US dollars, and
cost_basissays how it was reached: the service's own bill (reported: runware), a rate table (priced: arcana-ai 0.5.2), orestimated. - Every text is stored once, in one table: a prompt sent twice, a wording, an asset's description. Settings are stored once too.
AssetRecord#has_recipe?(JSONhas_recipe) says whether there's one to ask for;GET /assets/:id/recipereturns it over HTTP.
Before 0.13 an asset's generation was free-form JSON on its row. The first
read-write open by 0.13 moves each one, as it is, into a table of old
records and drops the column, since only each app can read its own;
generation keeps reading and writing that table through 0.13. Descriptions
move into the texts table the same way. Each column is dropped only after
every value is checked to read back the same from where it went; otherwise
the open fails and leaves the store as it was. It's one way: an older
DataDungeon can't open the store afterwards.
The first read-write open by 0.14 turns every recipe stored before it into
what was sent: a step's prompt_sent, or else a picture's fields joined in
0.13's order (rules last); speech's text, with its style as the
instructions; a chat text's description, with its instructions. Each named
field becomes one of its wordings, versioned by its words. Every step is
compared with its fields before a column goes; if any differs the open
fails and leaves the store as it was. 0.14 kept the fields, in
recipe_fields, no longer written; 0.15 drops them. It adds the
vocabulary, the ratings with DataDungeon's words for each (dd:pg). It's
one way:
upgrade every program that opens a store (an app and its dd-serve)
together.
set_recipe keeps what an asset has: it refuses to replace a recipe
without replace: true, and keeps the old generation record unless given
drop_generation: true, once the app is sure the recipe holds everything
it said. add_wordings(id, wordings, step: 0) names words a stored step
was sent but didn't name, such as an app's rating words on pictures made
before it named them. Only the new wordings are checked, as a new recipe's
are, and what the step named stays as it was stored. A text an asset or a recipe uses can't be deleted (a foreign key
refuses it), and DataDungeon deletes none.
Image fields
A picture's facts that are numbers live in image_meta columns, comparable
by range, rather than in its key (see DESIGN-image-fields.md). nil means
not known.
DataDungeon measures each picture it stores: width and height,
transparent (any pixel not fully opaque), dominant_color (the colour
most of the visible picture is, #rrggbb in JSON), the box around the
visible pixels (content_x/y/w/h), and for an animation its frames
(animation: count, duration, loops). PNG and WebP, still or animated, and
TIFF with data_dungeon/tiff (below), give all of these; a GIF its size and
frames and a JPEG its size, from their headers. measure_images(type, all:)
measures pictures stored before (each object is read, which for a bucket is
a download). Before 0.15.4 a 16-bit PNG with no gamma chunk was read as
linear light, too bright, and wrongly when interlaced; all: true measures
such pictures again. A picture whose header claims more than
ImageProbe.max_pixels (64 Mi, 8192 × 8192) isn't decoded, since its pixels
would be allocated before they're read: it's stored unmeasured, and
converting it raises. An application with bigger pictures raises the limit.
A file that isn't the picture it claims to be raises ImageProbe::Error
when it's converted (before 0.16.2, a PNG through libpng's own reader
ended the process).
The application sets what it asked the generator for: the view (where
the camera stands, a unit vector in the thing's frame, and its roll), the
light (where it comes from, in the picture's frame), the anchor (0 to 1
across and down: the point that sits on the ground), and the thing's real
size in metres (world_width, world_height, world_depth). Views and
light are written readably, as Direction: side front or back, yaw
toward the right (+1) or left, pitch from above (+1) or below, in quarter
turns. A profile facing left in the picture shows the thing's right side,
yaw +1. Each view has a name, back-left or front from-above or
top-down, which is also a search word; view_counts(type) counts a
type's pictures by each word of their names (kept as the tag counts are,
so instant), and the image_views SQL view shows views readably at the
sqlite3 prompt. Tag names starting with @ are DataDungeon's.
ViewWords.view(direction) and ViewWords.light(direction) put a view or
a light in words for a prompt ("a three-quarter view from its right, from
slightly above", "lit from high above and from the left"): @mj's camera's
words, lowercase fragments for the caller to punctuate, saying what was
asked, never what was drawn.
db.store("prop", {"thing" => "lamp"}, bytes: png, content_type: "image/png",
image: DataDungeon::ImageMeta.new(view: DataDungeon::Direction.new("back", -0.5),
light: DataDungeon::Direction.new("front", -0.5, 0.5), anchor_x: 0.5, anchor_y: 1.0, world_height: 3.0))
db.update_asset(id, image: DataDungeon::ImageMeta.new(world_width: 0.4)) # only the fields given
db.list_assets("prop", image: DataDungeon::ImageFilter.new({"world_height" => {2, 4}},
view: DataDungeon::Direction.new("front", 0.5), view_within: 30.0)) # a range, a view within 30 degrees
db.list_assets("prop", image: DataDungeon::ImageFilter.new(direction: "back-left"))
An animation's fields describe its first frame; set_keyframes(id, [...])
records what changes after it, as checkpoints holding only the fields that
change (ImageKeyframe), each gliding from the one before or held.
Neighbouring views may be at most 90 degrees apart, with a checkpoint
wherever something turns, so a filter matches a field at the first frame or
at any checkpoint exactly ("does it ever show the back?"). DataDungeon
measures the content box at each checkpoint of an animated WebP.
TIFF masters
require "data_dungeon/tiff" adds TIFF, through libtiff 4.5 or later, which
the application then links (dd-serve does). Without it, nothing links
libtiff. With it, DataDungeon:
- measures a TIFF as it does a PNG, whatever layout, compression or
depth libtiff reads, and stores it as
.tif; - stores a TIFF or a PNG as a master, asked with
ImageMeta.new(compression: "normalize", target_format: "tiff"): 8 bits a channel, in 64×64 tiles, deflate-compressed, with alpha only when a pixel isn't opaque. The pixels stay as they are, but 16-bit samples are rounded to the nearest 8-bit level (Thomas, 2026-10-20: 8-bit is enough). The same pixels always make the same file, and only the first page is kept, so export a flattened picture. An edit changes only the tiles it touches, so on transfs a small stroke costs 2.7 MiB over 37 pictures, against PNG's 32.9 (transfs-rs's chunk study); - makes a picture's WebP, a TIFF's or a PNG's:
store_webp(id, tags)stores it as a new asset of the picture's type, linkedderived-fromit with its quality in the link's data. It takes the picture's tags with these over them, its description and rating, and the image fields the application sets. It isn't permanent, since it can be made again, and storing it again with the same tags returns it.
require "data_dungeon/tiff"
master = db.store("prop", {"venue" => "boardwalk", "thing" => "barrel", "fmt" => "tif"},
bytes: File.read("barrel.tif").to_slice, content_type: "image/tiff", # as GIMP exports it
image: DataDungeon::ImageMeta.new(compression: "normalize", target_format: "tiff", anchor_x: 0.5, anchor_y: 1.0))
webp = db.store_webp(master.id, {"fmt" => "webp"}) # quality: 85
An .xcf (image/x-xcf), GIMP's own format, is stored as given, for the
odd original kept in it. DataDungeon can't read one, so it isn't measured
and has no WebP (Thomas, 2026-10-20).
Tracks
A track is what a tool measured of a sound or a video over time, kept with
it: a take's word times and mouth cues, so they travel with the take and
any app can lip-sync it without running the tools again (0.15.3). Each
cue runs from start_ms to end_ms, in time order.
cue = DataDungeon::TrackCue
db.set_track(take.id, "words", [cue.new(0, 180, "My"), cue.new(180, 520, "friend!")], tool: "whisper-1")
db.set_track(take.id, "mouth", [cue.new(0, 60, "X"), cue.new(60, 120, "B")], tool: "rhubarb 1.14.0 --dialogFile")
db.tracks(take.id) # each track's name, tool, made_at and cues
db.track(take.id, "mouth") # one, or nil
DataDungeon::DB::STANDARD_TRACKS # the shared names: words, mouth, subtitles
words,mouthandsubtitlesare shared: when each word is spoken, its value the word with its punctuation (friend!), as written when the tool aligns a known text (a voice engine's own timings, a forced aligner) and as heard when it transcribes (Whisper); and the mouth's shape, its values the tool's (Rhubarb Lip Sync's A to H, and X at rest); and the lines shown as subtitles, a cue for each, its value the line as shown (0.16.5). Other names are an app's own (words-heard, for a transcription kept beside the text's).- A track names its tool, with its version and any options that change
its result (
rhubarb 1.14.0 --dialogFile), so a better one can redo it: setting a track replaces the one of that name whole. - Only the start order is checked: a cue may last no time, and may run past the next one's start.
- They're measured from the recording, not the script: a retake has its own. Tracks go when their asset does.
- A
wordstrack is a tool's timing of the words, not their text: for the words as text, corrected by hand, with who speaks or with sounds that aren't words, keep atranscriptlinkedtranscript-ofthe recording (DESIGN-types).
Storage
cache_root: stores objects on local disk (LocalStorage). For an
S3-compatible bucket (Cloudflare R2, Garage, MinIO or AWS S3), require
data_dungeon/s3, add awscr-s3 to your shard.yml, and pass an
S3Storage:
require "data_dungeon/s3"
storage = DataDungeon::S3Storage.r2(ENV["R2_ACCOUNT_ID"], "game-assets",
access_key: ENV["R2_ACCESS_KEY_ID"], secret_key: ENV["R2_SECRET_ACCESS_KEY"],
public_url: "https://assets.example.com") # optional: a domain or CDN in front
db = DataDungeon::DB.new("assets.db", storage: storage)
db.read(record) # the object's bytes, from whichever storage
db.open(record) { |io| IO.copy(io, response) } # streamed, not loaded whole
db.path_for(record) # a URL: under public_url, or presigned without one
S3Storage.new(bucket, endpoint:, region:, access_key:, secret_key:) reaches
any other S3-compatible service. Objects are uploaded with their content type
and immutable cache headers, since content-addressed objects never change.
Connections are kept alive, and requests the service turns away for load
(429, 5xx) are retried with backoff. Give a store a bucket, or a prefix:
in one, of its own, or share it as below: check_consistency treats every
object under it as the store's.
With remote storage, lookups don't confirm that each record's object still
exists, since that would cost a round trip per read; check_consistency finds
any drift. Read objects with DB#read, or stream them with DB#open, rather
than opening path_for, which is only a file path for local storage.
DB#find_by_hash finds the assets holding given bytes, by their SHA-256.
Stores can share one bucket, each object kept once by its hash, with
shared: true on the storage (DD_SHARED_STORAGE=1 for dd-serve). A store
can't know whether another still uses an object, so DataDungeon never
deletes one there: deleting or pruning an asset removes its row only, and
vacuum won't delete objects. It also writes each object it stores even
when it's there, so the object's date is when a store last relied on it.
Clearing objects no store uses is for a program that asks every store for
object_keys (GET /objects) and keeps whatever any of them names, and
anything written since it began.
Each store is one owner's: the app that opens it (consumer:) and its
dd-serve (Thomas, 2026-10-16). An app that wants another store's assets
copies the records into its own, with a copied-from link saying where
from, and the bytes stay one object in the shared bucket. Pictures aren't
expected to move between apps (Thomas, 2026-10-17): each app names and
words its own styles for its own models, and reuse pays within an app.
Run the specs with crystal spec. The S3 specs need a server:
scripts/garage-test runs them against a throwaway single-node
Garage (the garage binary on PATH).
Browsing, curating and the HTTP API
asset_types lists every type with its count of current assets
(retired: true, of retired ones); tag_keys(type) a type's tag names and
tag_values(type, key) one tag's values, each with a count. list_assets
gives a page of assets, newest filed first, of one type or of all, filtered
by tags, links and search words. Listings and counts take current assets,
or with retired: true retired ones, nil both. offset numbers pages (about
2 ms to skip 100,000 assets); to walk through them all, pass the last
asset's id as before for the next page. count_assets counts them all:
db.list_assets("prop", {"venue" => "boardwalk"})
db.list_assets(q: "cattacula night") # the type or tag values (or parts), any order
db.list_assets(q: "catt* night") # catt* matches the start of a word
db.list_assets(q: "red-hair") # a labelled trait, only as keys spell it
db.list_assets(links: {"used-by" => "boardwalk"}, limit: 50, before: last_id)
db.list_assets(links: {"used-by" => "boardwalk.*"}) # boardwalk and names under it
db.list_assets("prop", unlinked: "used-by") # what nothing uses
db.list_assets("speech", retired: true) # what was retired or superseded
db.list_assets(file_hash: hash) # the assets holding a file, such as a recipe's reference
db.list_assets(description_hash: rec.description_hash) # every asset sharing a description
Search words come from a full-text index (SQLite FTS5) of each asset's type
and tag values, kept current as assets change. update_asset(id, tags:, description:, flagged:, rating:, permanent:) edits an asset in place
without storing its bytes again; a rating and a flag change which asset
get prefers. A blank description clears it. Assets can share one
description (the same description_hash, its SHA-256, the same in every
store); a new one is that asset's alone, unless everywhere: true gives it
to every asset that shared the old one, as for a typo. The old wording is
kept, since a recipe may record it as what a provider was sent.
link(from, rel, to, data) relates an asset to another asset
(derived-from) or to a name (used-by, "boardwalk.holidayland"),
outside its key, so the link can change freely. Names nest under their user
with dots. data is JSON kept with the link, such as how the asset is
placed in that use; linking again replaces it. links_from and links_to
list them, and name filters take boardwalk.holidayland exactly,
boardwalk.* for it and every name under it (not boardwalk-annex), or
boardwalk* for every name starting so. Links go when either asset is
deleted; a supersedes link is made by superseding. Opening with
DB.new(path, storage, read_only: true) changes nothing: no schema setup, no
re-keying, writes refused by DataDungeon (ReadOnlyError) and by SQLite.
require "data_dungeon/http" adds DataDungeon::API, a JSON API over a DB
for a browser elsewhere. It's an HTTP::Handler, so an application can
mount it, or dd-serve (shards build dd-serve) runs it alone beside the
database, configured by DD_* environment variables (see
src/dd_serve.cr). Reads need the read token, writes the write token
(Authorization: Bearer …); without a write token there are no writes, and
dd-serve opens the database read-only, unless it embeds for search
(DD_EMBED_MODEL, see Semantic asset search). A file added by hand is
read no further than max_upload: (256 MiB, dd-serve's DD_MAX_UPLOAD),
and a bigger one gets 413; a picture it can't decode gets 400.
api = DataDungeon::API.new(db, read_token: ENV["DD_READ_TOKEN"], write_token: ENV["DD_WRITE_TOKEN"]?,
prefix: "/dd")
HTTP::Server.new([api, app_handler])
GET /types | types, counts (current and retired), registrations and indexes |
GET /types/:type/tags[/:key] | tag names, or one tag's values, with counts |
GET /types/:type/views | how many pictures are seen from each named direction |
GET /assets?type=&q=&tag.<key>=&link.<rel>=&unlinked=&limit=&page=&before=&count=1 | a page of assets with their tags, and next (&meta=1: each picture's fields and view too) |
…?retired=only or =include | retired assets, or all, in listings and counts |
…?field.<name>=<low>..<high>&view=back-left&view.side=&view.yaw=&view.pitch=&view.within= | by picture fields (and light. alike) |
…?file_hash=&description_hash=&text.<kind>=<owner:name>[@<version>] | by file, by shared description, by a wording a recipe used |
GET /assets/:id, /assets/:id/object, /assets/:id/links | an asset with its tags, metadata and links; its bytes; its links |
POST /maintenance/check | the consistency report |
GET /objects | every object key the store's rows use, for clearing a shared bucket |
GET /vocabulary | the shared tags and their values, with their titles; each rating with DataDungeon's words for it; DataDungeon's asset types; the shared tracks |
GET /assets/:id/tracks[/:name] | its tracks, or one (404 when it has none of that name) |
POST /assets | a file added by hand, as multipart/form-data: file (its Content-Type the asset's) and asset, JSON {type, tags, custom, description, permanent, made_at, supersedes, image}; stored as not generated, with no recipe; image as PATCH takes it, plus how to store the file ({"compression": "normalize", "target_format": "tiff"} for a TIFF master); 409 with asset_id when its type already holds the bytes; 413 over max_upload (write token) |
POST /assets/:id/webp | {tags, quality}: the picture's WebP, as an asset of its own (store_webp); 409 as above (write token) |
PATCH, DELETE /assets/:id | edit (with image fields, views readably, and everywhere for a shared description) or delete (write token) |
PUT /assets/:id/keyframes | an animation's checkpoints (write token) |
PUT /assets/:id/tracks/:name | {tool, cues: [{start_ms, end_ms, value}]}: a track, replacing the one of that name (write token) |
POST, DELETE /assets/:id/links | link (with data) or unlink (write token) |
GET /assets/:id/recipe | how it was made (404 when it has no recipe) |
POST /assets/:id/retire, /assets/:id/reinstate | retire or reinstate (write token) |
POST /maintenance/prune | {type, keep_last, retired_before}: delete retired assets (write token) |
POST /maintenance/measure | {type, all}: measure stored pictures (write token) |
POST /types/:type/promote, /maintenance/vacuum | (write token) |
GET /search?q=&type=&media=&tag.<key>=&key=&key_prefix=&facet=&limit=&min_score= | assets by meaning, best first, each with its tags and score (given a searcher, as dd-serve with DD_EMBED_MODEL has) |
GET /search?type=&facet.<name>=<query>… | by several facets at once, each with its own query: an asset scores its weakest |
GET /search/status | the store's embedding model, and how many texts wait to be embedded or failed |
An asset in JSON carries its key (pool_key before 0.12, and both names
through 0.12), rating, flagged and permanent, which PATCH sets,
description_hash, and has_recipe.
Best matches
require "data_dungeon/matches" (with the usearch shard and its C
library) adds DataDungeon::MatchIndex: for a registered type, the assets
closest to what was picked, even when none matches every pick, ranked by
score = Σ weight × similarity over the traits asked for.
db.register_type("plug", pool_traits: %w[art-style rating race hair skin items],
scales: {"rating" => %w[g pg r x]}, # ordered: near values count as close
colors: %w[hair skin], # CSS names or hex codes, by appearance
sets: %w[items], # by shared members
weights: {"art-style" => 2.0}) # default weights (else 1)
index = DataDungeon::MatchIndex.new(db)
index.best_matches("plug", {"art-style" => "golden", "rating" => "pg", "hair" => "crimson"},
require: %w[art-style rating], # must match exactly
weights: {"hair" => 3.0}, limit: 10) # => [BestMatch(asset, score), …], best first
index.similar(asset_id, limit: 10) # by an asset's own traits
index.close
cycles lists values that go round a circle ({"time" => %w[dawn day dusk night]}), so night's nearest are dusk and dawn, and day is furthest.
match_fields: %w[view light roll dominant-color] matches pictures on
those image fields too: views and light by the angle between them (nothing
from 90 degrees apart), colours by appearance. A query picks them like
traits: {"view" => "back-left", "dominant-color" => "#aa3311"}, a view
also as "front,0.5,0" (side, yaw, pitch).
Other traits are words, alike only when equal; a color name DD doesn't know matches only itself. What must match and what matters most are the application's to say: nothing is required, and every trait weighs 1, unless given.
Each asset has a vector built from its tags, one block per trait, whose
inner product with a query's approximates the score; USearch finds 200
candidates in one file per type (beside the database), and those are
scored exactly. Weights travel with the query, so changing them rebuilds
nothing; changing a type's traits, scales or colors rebuilds its file from
the tags. One process owns a store's match files (a lock) and, before each
query, catches up on every process's writes from the database's change
log. DataDungeon::API answers GET /types/:type/matches and
GET /assets/:id/similar when given one, as dd-serve is. See DESIGN-best-match.md and bench/best_match.cr: at
200k assets a query takes about 1.4 ms (3.4 ms with required traits), with
the exhaustive top 10 found every time.
Semantic asset search
Semantic search finds assets by meaning: "a wizard's study at night" finds
the picture described as "a cosy scholar's study at night, crowded with
books". require "data_dungeon/semantic" adds DataDungeon::SemanticIndex,
which needs the memo shard, ~> 0.15.6 (and libmagic; Memo brings crystal-db 0.15,
so sqlite3 0.24). It keeps Memo's tables in the
same SQLite database and its USearch index file beside it.
What's embedded is the type's to say, in its registration: embed:
lists the parts, description and tag names. A type registered without
embed isn't embedded, so nothing goes to the embedding model unless an
app asks. Each asset's text is its description as written, then one
statement per tag, by name, whatever order embed gives:
db.register_type("drop", pool_traits: %w[world], embed: %w[description subtype])
# A cosy stone-and-timber scholar's study at night, crowded with books
# Subtype is scene.
Statements searched best of the forms measured (DESIGN-embeddings.md).
Name the tags that say what the asset is (race, hair colour, a subtype), not
exact constraints such as art-style and rating, which belong in a
search's tags: filter, nor hashes, ids or slugs. A recipe's prompt is never
embedded: since 0.14 it carries style and rules words many assets share,
which drown out what differs. Changing embed needs no rekey:; it embeds
the type's assets again.
require "data_dungeon/semantic"
db = DataDungeon::DB.new("assets.db", cache_root: "asset-files")
semantic = DataDungeon::SemanticIndex.new(db,
format: "openai", model: "text-embedding-3-small", api_key: ENV.fetch("OPENAI_API_KEY"))
semantic.catch_up # queue what changed, here or in another process; safe to repeat
semantic.process_queue # embed it: one worker per store, off the request path
hits = semantic.search("curly-haired adventurer",
asset_type: "plug", tags: {"art-style" => "silver"})
hits.each { |hit| puts "#{hit.asset.id}: #{hit.score}" }
db.close
One model per store. Vectors from two models can't be compared, so the
first model used is the store's, and opening the index with another raises.
switch_model: true drops the old vectors; the next catch_up queues
everything to be embedded again.
dd-serve searches when given a model: DD_EMBED_MODEL
(text-embedding-3-small) and DD_EMBED_KEY, and DD_EMBED_FORMAT for an
API other than OpenAI's (voyage, or mock with model mock-8d to try it
without a key). It is then the store's one worker: every few seconds it
catches up on what any process stored, re-described, re-registered or
deleted (the store's change log), and embeds it, so an app needs no index of
its own. It opens the store read-write to keep the vectors; its API still
takes no writes without DD_WRITE_TOKEN. It answers GET /search and
GET /search/status (see the API table above). Building it needs the memo
shard beside usearch and awscr-s3.
A registered type can also declare facets: named groups of tags embedded
separately from the asset's own embedding, so a search can rank one part of
an asset without the rest diluting it. Each facet is its own Memo source type
(<asset_type>.<facet>) in the same store, written the same way;
description names the asset's description.
db.register_type("drop", pool_traits: %w[city rooms],
facets: {"location" => %w[neighborhood view], "interior" => %w[style finishes description]})
semantic.search("sunny loft with exposed brick", asset_type: "drop", facet: "interior")
semantic.search_facets({"location" => "quiet street near a park",
"interior" => "sunny loft with exposed brick"},
asset_type: "drop", key_prefix: db.group_key_for("drop", {"city" => "oslo"}, 1))
search_facets keeps only assets that have every facet named, and scores
each by its weakest facet, so a close match on one part doesn't make up for a
poor match on another (hit.facet_scores has each). It ranks each facet to a
depth of 500 assets by default, so combine within a scope (a pool key, key
prefix or tags) small enough to be ranked whole.
Hits come best first, each with its score, up to limit. There is no score
cutoff unless you pass min_score:. Scores aren't comparable across
embedding models or query lengths, so choose a threshold per decision: offering
a player choices may need none, while reusing an asset outright needs one
tested against real queries.
Search can also filter by media ("image", "audio", "text", "video", …).
For a registered type, key: ranks only the assets with one key and
key_prefix: only those whose leading traits match, such as every picture of
one portrait:
pictures = semantic.search("the hero fights the troll",
key_prefix: db.group_key_for("plug", {"portrait" => portrait_id}, 1))
Those lookups start from the key index, so their cost follows the size of the pool rather than of the catalogue, and Memo ranks that set exactly.
Once attached, storing an asset queues it for embedding; deleting an asset
removes its Memo entry. Changes to the description or embedded tags are
re-embedded by the worker; changes another process makes, once catch_up
reads them from the change log. Until the worker runs, search may still use
the previous embedding. Memo saves the index file on its own policy (after
many unsaved changes or a few minutes) and when the database closes, and it
also keeps every vector in the database: after a crash or a lost index file,
Memo replays or rebuilds the index when it opens, without embedding anything
again. catch_up's first call, like backfill, checks every asset and
queues those whose text has no vector yet; both are safe to repeat. An asset
whose parts are all empty is not indexed. Search returns asset records
without changing usage or match counters, and its query cache stays in
memory.
Documentation
Built from the current release. The first visit to a release nobody has asked for starts its build.
Links
This release
- Version
0.11.1- Tagged
- Oct 25, 2026
- Commit
e0e3a428f288- Indexed
- not yet
Dependents
No indexed shard depends on this one yet.
Repository
github.com/trans/datadungeon
Metadata
- Created
- Oct 27, 2026
- Updated
- Nov 6, 2026
- Synced
- Nov 6, 2026
- Versions
- 37