Document the .beam SQLite format and port the inspector
The .beam container moved from ZIP to SQLite, but the docs/tooling still described the old ZIP format. - Rewrite BEAM_FILE_FORMAT.md as a normative spec of the current SQLite container: file-magic identification, the four-table schema, the two version numbers, the media model (kinds, packed/referenced storage, 4 MiB chunking), derived media-id formulas, project.json top-level + key entities, save/load semantics, the legacy-ZIP format + migration, large-media policy, conformance and security sections, and known quirks. - Port beam_inspector.py to read SQLite (auto-detecting container by magic, with the legacy ZIP path preserved): a --media store view, packed/referenced storage detection, and chunk-reassembling media extraction. - Update beam_inspector_README.md to match.
This commit is contained in:
parent
bf4331eed4
commit
5f0d5354ed
|
|
@ -0,0 +1,484 @@
|
|||
# `.beam` File Format Specification
|
||||
|
||||
**Status:** Normative.
|
||||
**Container schema version:** `1` (`meta.schema_version`).
|
||||
**Project payload version:** `1.0.0` (`BeamProject.version` / `meta.version`).
|
||||
**Last updated:** 2026-06-21.
|
||||
|
||||
This document specifies the on-disk format of Lightningbeam `.beam` project files
|
||||
precisely enough that an independent implementation can read and write them. It
|
||||
describes the **current** format — a SQLite database — and the **legacy** format
|
||||
(a ZIP archive) that current readers still accept and migrate.
|
||||
|
||||
> An earlier revision of this document described the ZIP container as the current
|
||||
> format. That is now historical; see [§11 Legacy ZIP format](#11-legacy-zip-format-pre-sqlite).
|
||||
|
||||
## 1. Notational conventions
|
||||
|
||||
The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are
|
||||
to be interpreted as described in RFC 2119.
|
||||
|
||||
Byte sizes use binary units: `KiB = 1024`, `MiB = 1024²`, `GiB = 1024³`.
|
||||
|
||||
"UUID" means an RFC 4122 128-bit identifier. Unless stated otherwise, a UUID is
|
||||
serialized in JSON as its canonical hyphenated lowercase string, and stored in
|
||||
SQLite as its 16 raw bytes in **network (big-endian) byte order** — i.e. the byte
|
||||
sequence returned by `Uuid::as_bytes`, most significant byte first.
|
||||
|
||||
## 2. Overview
|
||||
|
||||
A `.beam` file is a **single SQLite 3 database** (default rollback journal; no WAL,
|
||||
no special pragmas) containing:
|
||||
|
||||
- one JSON document, `project.json`, holding all project state that is small and
|
||||
structural (the document/scene tree, audio project, asset metadata); and
|
||||
- zero or more **media** items (audio, raster frames, image assets, and derived
|
||||
blobs such as waveforms and thumbnails), each either **packed** into the database
|
||||
in chunks or **referenced** by an external file path.
|
||||
|
||||
The design goals are: a single beginner-friendly file (no project folder);
|
||||
streaming reads of large media via SQLite blob I/O; transactional, crash-safe,
|
||||
**in-place** saves that do not rewrite unchanged media; and inspectability with the
|
||||
ordinary `sqlite3` CLI.
|
||||
|
||||
## 3. File identification
|
||||
|
||||
A reader MUST determine the container type from the first 16 bytes of the file:
|
||||
|
||||
- If the first 16 bytes equal the ASCII string `SQLite format 3\0` (i.e.
|
||||
`53 51 4C 69 74 65 20 66 6F 72 6D 61 74 20 33 00`), the file is a SQLite
|
||||
`.beam` and MUST be read per [§4](#4-container-the-sqlite-schema)–[§10](#10-load-semantics).
|
||||
- Otherwise the file is treated as a [legacy ZIP `.beam`](#11-legacy-zip-format-pre-sqlite)
|
||||
(ZIP local-file-header magic `50 4B 03 04`).
|
||||
|
||||
The `.beam` extension is used for both container types; the magic bytes — not the
|
||||
extension — are authoritative.
|
||||
|
||||
## 4. Container: the SQLite schema
|
||||
|
||||
A conforming SQLite `.beam` MUST contain exactly these four tables (DDL as created;
|
||||
column order and types are normative):
|
||||
|
||||
```sql
|
||||
CREATE TABLE media (
|
||||
id BLOB PRIMARY KEY, -- 16-byte UUID (big-endian)
|
||||
kind INTEGER NOT NULL, -- MediaKind (§6.1)
|
||||
codec TEXT NOT NULL, -- original codec/container, e.g. "flac","mp3","png"
|
||||
storage INTEGER NOT NULL, -- MediaStorage (§6.2): 0=Packed, 1=Referenced
|
||||
ext_path TEXT, -- external path, set iff storage=Referenced
|
||||
total_len INTEGER NOT NULL DEFAULT 0, -- payload length in bytes (packed); 0 if referenced
|
||||
channels INTEGER, -- audio: channel count (nullable)
|
||||
sample_rate INTEGER, -- audio: sample rate in Hz (nullable)
|
||||
width INTEGER, -- visual media: pixel width (nullable)
|
||||
height INTEGER -- visual media: pixel height (nullable)
|
||||
);
|
||||
|
||||
CREATE TABLE media_chunk (
|
||||
id INTEGER PRIMARY KEY, -- rowid (used as the handle for blob streaming)
|
||||
media_id BLOB NOT NULL, -- → media.id
|
||||
chunk_index INTEGER NOT NULL, -- 0-based ordinal of this chunk
|
||||
bytes BLOB NOT NULL, -- chunk payload
|
||||
UNIQUE (media_id, chunk_index)
|
||||
);
|
||||
|
||||
CREATE TABLE project_json (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 0), -- exactly one row, id 0
|
||||
data TEXT NOT NULL -- the serialized BeamProject (§8)
|
||||
);
|
||||
|
||||
CREATE TABLE meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
There is no foreign-key constraint between `media_chunk.media_id` and `media.id`;
|
||||
referential integrity is maintained by the writer. A reader MUST NOT assume SQLite
|
||||
enforces it.
|
||||
|
||||
### 4.1 The `meta` table
|
||||
|
||||
Defined keys (all values are strings):
|
||||
|
||||
| Key | Value | Meaning |
|
||||
|------------------|--------------------|---------|
|
||||
| `schema_version` | `"1"` | Container schema version. Set once at creation. |
|
||||
| `version` | `"1.0.0"` | Project payload version (mirrors `project.json`'s `version`). |
|
||||
| `created` | RFC 3339 timestamp | Creation time. Preserved across in-place re-saves. |
|
||||
| `modified` | RFC 3339 timestamp | Last save time. Updated on every save. |
|
||||
|
||||
Writers MAY add additional keys; readers MUST ignore unknown keys.
|
||||
|
||||
### 4.2 Two independent version numbers
|
||||
|
||||
The format carries **two** version numbers with different compatibility rules:
|
||||
|
||||
- **`meta.schema_version`** (`INTEGER`, currently `1`) — versions the SQLite
|
||||
container/table layout. A reader MUST reject a file whose `schema_version` is
|
||||
**greater** than the highest it supports, and SHOULD accept any value less than
|
||||
or equal to its maximum (forward-compatible for older files).
|
||||
- **`BeamProject.version`** / `meta.version` (currently `"1.0.0"`) — versions the
|
||||
`project.json` payload. The current implementation requires **exact string
|
||||
equality** and rejects any other value. A future revision MAY relax this to a
|
||||
semantic-version range; until then, writers MUST emit exactly `"1.0.0"` and
|
||||
readers reject anything else.
|
||||
|
||||
These two numbers are orthogonal and MUST both be checked.
|
||||
|
||||
## 5. UUIDs and identity
|
||||
|
||||
Every media item is identified by a UUID. In `project.json` UUIDs appear as
|
||||
canonical strings; in the `media`/`media_chunk` tables they appear as 16 raw
|
||||
big-endian bytes (`media.id`, `media_chunk.media_id`). A reader MUST treat the two
|
||||
representations as equal by their 128-bit value.
|
||||
|
||||
`media_chunk.id` is the SQLite rowid and is the handle a reader uses to open a blob
|
||||
for streaming (`sqlite3_blob_open` on table `media_chunk`, column `bytes`). It has
|
||||
no meaning beyond row identity and MUST NOT be relied upon to be stable across
|
||||
re-saves.
|
||||
|
||||
## 6. Media model
|
||||
|
||||
### 6.1 `MediaKind` (`media.kind`)
|
||||
|
||||
| Value | Kind | `codec` examples | Notes |
|
||||
|-------|---------------|------------------|-------|
|
||||
| `0` | `Audio` | `flac`, `mp3`, `wav`, `ogg`, `opus`, `aac`, `m4a`, `alac`, `caf`, `aiff` | Source audio for a pool entry. |
|
||||
| `1` | `Video` | — | **Reserved/unused.** The current writer never emits video rows; video bytes live in an external file referenced by `VideoClip.file_path`. |
|
||||
| `2` | `Raster` | `png` | Full-resolution pixels of a raster keyframe (PNG-encoded RGBA). |
|
||||
| `3` | `ImageAsset` | `png`, `jpg`, … | An imported image asset's original bytes. |
|
||||
| `4` | `Waveform` | `lbwf` | Precomputed waveform LOD pyramid for an audio item. Opaque blob owned by `daw_backend::audio::waveform_pyramid`. |
|
||||
| `5` | `Thumbnail` | `lbtn` | A pack of precomputed video thumbnails for a clip. Opaque blob owned by the editor. |
|
||||
| `6` | `RasterProxy` | `png` | Low-resolution PNG proxy of a raster keyframe, shown during cold scrubs while full-res pages in. |
|
||||
|
||||
A reader MUST reject a `kind` value it does not recognise only if it needs that
|
||||
item; unknown kinds MAY otherwise be ignored.
|
||||
|
||||
### 6.2 `MediaStorage` (`media.storage`)
|
||||
|
||||
| Value | Storage | Bytes location | `total_len` | `ext_path` |
|
||||
|-------|--------------|----------------|-------------|------------|
|
||||
| `0` | `Packed` | `media_chunk` rows in this DB | payload length | `NULL` |
|
||||
| `1` | `Referenced` | external file at `ext_path` | `0` | path string |
|
||||
|
||||
### 6.3 Packed storage and chunking
|
||||
|
||||
Packed bytes are split into chunks of **`CHUNK_SIZE = 4 MiB`** and stored one chunk
|
||||
per `media_chunk` row, ordered by `chunk_index` ascending starting at `0`. The
|
||||
writer MUST:
|
||||
|
||||
1. set `media.total_len` to the exact total byte length of the payload;
|
||||
2. write `ceil(total_len / CHUNK_SIZE)` chunk rows (zero rows iff `total_len == 0`);
|
||||
3. make every chunk exactly `CHUNK_SIZE` bytes **except the last**, which holds the
|
||||
remainder `total_len − (n−1)·CHUNK_SIZE`.
|
||||
|
||||
Because chunk lengths are fully determined by `total_len` and `CHUNK_SIZE`, a reader
|
||||
performing random access MUST compute, for a byte offset `pos < total_len`:
|
||||
|
||||
```
|
||||
chunk_index = pos / CHUNK_SIZE
|
||||
offset_in_chunk = pos % CHUNK_SIZE
|
||||
chunk_len = min(CHUNK_SIZE, total_len − chunk_index·CHUNK_SIZE)
|
||||
```
|
||||
|
||||
and read from the row with that `chunk_index`. A reader MAY stream the whole payload
|
||||
by concatenating chunk `bytes` in `chunk_index` order. The chunk size MAY differ in
|
||||
files written by other tools; a robust reader SHOULD derive chunk boundaries from
|
||||
actual row lengths rather than assuming 4 MiB. (The reference reader assumes uniform
|
||||
`CHUNK_SIZE` except for the last chunk; writers targeting it MUST keep chunks
|
||||
uniform.)
|
||||
|
||||
### 6.4 Referenced storage
|
||||
|
||||
A referenced item stores only `ext_path` (with `total_len = 0` and no chunk rows).
|
||||
The path is resolved relative to the directory containing the `.beam` file unless it
|
||||
is absolute. If the file is absent at load time, the item is reported as a *missing
|
||||
file* ([§10.4](#104-missing-referenced-files)) — this is non-fatal.
|
||||
|
||||
## 7. Derived media IDs
|
||||
|
||||
Three media kinds are keyed by UUIDs **derived** from another id rather than by an
|
||||
independent random UUID. A reader/writer MUST compute them exactly as follows
|
||||
(`from_u128` constructs a UUID from a 128-bit big-endian integer):
|
||||
|
||||
| Kind | Derived from | Formula |
|
||||
|---------------|---------------------|---------|
|
||||
| `Waveform` | audio pool **index** `i` (`usize`) | `from_u128((0x4C42_5746 << 96) \| (i as u128))` — high 32 bits = `0x4C425746` ("LBWF"), low 96 bits = the pool index. |
|
||||
| `Thumbnail` | video clip UUID `c` | `from_u128(c.as_u128() ^ 0x4C42_544E_4C42_544E_4C42_544E_4C42_544E)` — full-width XOR with "LBTN" repeated 4×. |
|
||||
| `RasterProxy` | raster keyframe UUID `k` | `from_u128(k.as_u128() ^ 0x4C42_5058_4C42_5058_4C42_5058_4C42_5058)` — full-width XOR with "LBPX" repeated 4×. |
|
||||
|
||||
The full-resolution `Raster` row is keyed by the keyframe's **own** UUID (no
|
||||
derivation); an `ImageAsset` row is keyed by the asset's own UUID; a packed `Audio`
|
||||
row is keyed by the pool entry's `media_id`.
|
||||
|
||||
> Note the asymmetry: the waveform id ORs the pool index into the high bits, while
|
||||
> thumbnail/proxy ids XOR a 128-bit sentinel with a source UUID. This is intentional
|
||||
> (waveforms are keyed by *index*, not by a UUID) but is a sharp edge for
|
||||
> implementers.
|
||||
|
||||
## 8. `project.json`
|
||||
|
||||
`project_json.data` is a UTF-8 JSON document: the serde serialization of a
|
||||
`BeamProject`. This section specifies the top-level structure and the entities a
|
||||
reader needs to resolve media; the deep UI/scene tree is defined by the
|
||||
corresponding Rust types (serde field names match struct field names unless a
|
||||
`#[serde(rename)]` is noted) and is intentionally **not** enumerated exhaustively
|
||||
here.
|
||||
|
||||
### 8.1 `BeamProject` (root)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-----------------|---------------------------|-------|
|
||||
| `version` | string | MUST be `"1.0.0"`. |
|
||||
| `created` | string (RFC 3339) | |
|
||||
| `modified` | string (RFC 3339) | |
|
||||
| `ui_state` | `Document` | The scene/document tree (§8.2). |
|
||||
| `audio_backend` | `SerializedAudioBackend` | Audio engine state (§8.3). |
|
||||
|
||||
### 8.2 `Document` (top-level fields)
|
||||
|
||||
Collections that carry media linkage are **bold**:
|
||||
|
||||
- `id: Uuid`, `name: string`, `background_color`, `width: f64`, `height: f64`,
|
||||
`framerate: f64`, `duration: f64`
|
||||
- `time_signature`, `master_layer`, `timeline_mode` — all `#[serde(default)]`
|
||||
- `root: GraphicsObject` — the layer tree; raster keyframes live here and inside
|
||||
nested group/clip layers
|
||||
- **`image_assets: map<Uuid, ImageAsset>`** — keyed by asset UUID (= the media id)
|
||||
- **`video_clips: map<Uuid, VideoClip>`** — `VideoClip.file_path` is the external
|
||||
video file; thumbnails are derived media (§7)
|
||||
- `vector_clips`, `audio_clips`, `instance_groups`, `effect_definitions`,
|
||||
`script_definitions`, the various `*_folders` asset trees — structural, no direct
|
||||
media-row linkage
|
||||
- `ui_layout`, `ui_layout_base` — `Option`, skipped when `None`
|
||||
- `current_time`, `layer_to_clip_map` — `#[serde(skip)]` (not persisted)
|
||||
|
||||
`RasterKeyframe` (within the layer tree) has `id: Uuid` (= its full-res `Raster`
|
||||
media id and the seed for its `RasterProxy` id), `time`, `width`, `height`,
|
||||
`tween_after`, `stroke_log`, and a `media_path` string that is **vestigial** in
|
||||
SQLite files (it names the legacy ZIP entry path; the SQLite path keys on `id` and
|
||||
ignores it). Pixel buffers are `#[serde(skip)]` and faulted in from media rows.
|
||||
|
||||
### 8.3 `SerializedAudioBackend`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|----------------------|---------------------|-------|
|
||||
| `sample_rate` | `u32` | **Unreliable:** the current writer hardcodes `48000` here; authoritative rates are on `AudioProject.sample_rate` and each `AudioPoolEntry.sample_rate`. |
|
||||
| `project` | `AudioProject` | Tracks + MIDI clip pool (§8.4). |
|
||||
| `audio_pool_entries` | `[AudioPoolEntry]` | Audio source registry (§8.5). |
|
||||
| `layer_to_track_map` | `map<Uuid, u32>` | UI layer UUID → engine track id. `#[serde(default)]`. |
|
||||
|
||||
### 8.4 `AudioProject` (top-level fields)
|
||||
|
||||
`tracks: map<TrackId, TrackNode>`, `root_tracks: [TrackId]`, `next_track_id`,
|
||||
`sample_rate: u32`, `midi_clip_pool`, `next_midi_clip_instance_id`. DSP graphs are
|
||||
not serialized; they are rebuilt on load.
|
||||
|
||||
### 8.5 `AudioPoolEntry` (media linkage)
|
||||
|
||||
| Field | Type | Role |
|
||||
|-----------------|----------------------------|------|
|
||||
| `pool_index` | `usize` | Stable index; seeds the `Waveform` media id (§7). |
|
||||
| `name` | string | |
|
||||
| `media_id` | `Option<string>` (UUID) | Set ⇔ audio bytes are **packed** in this DB under that id. `#[serde(default, skip_serializing_if=None)]`. |
|
||||
| `relative_path` | `Option<string>` | Set ⇔ audio is **referenced** (external file) or missing. |
|
||||
| `embedded_data` | `Option<EmbeddedAudioData>`| Legacy/inline: `{ data_base64, format }`. Used when bytes are neither packed nor referenced. |
|
||||
| `sample_rate` | `u32` | Authoritative sample rate. |
|
||||
| `channels` | `u32` | Channel count. |
|
||||
| `duration` | `f64` | Seconds. |
|
||||
| `is_video_audio`| `bool` | If set, the entry is the audio track of a video; always stored **referenced**. `#[serde(default, skip_if=false)]`. |
|
||||
| `waveform_blob` | (transient) | `#[serde(skip)]`; carries waveform bytes in memory only. |
|
||||
|
||||
### 8.6 Media linkage summary
|
||||
|
||||
| Media kind | Referenced from `project.json` by | Media-row id |
|
||||
|--------------------|-----------------------------------|--------------|
|
||||
| Audio (packed) | `AudioPoolEntry.media_id` | that UUID |
|
||||
| Audio (referenced) | `AudioPoolEntry.relative_path` | — (external) |
|
||||
| Audio (embedded) | `AudioPoolEntry.embedded_data` | — (inline base64) |
|
||||
| Raster (full) | `RasterKeyframe.id` | that UUID |
|
||||
| Raster proxy | derived from `RasterKeyframe.id` | §7 |
|
||||
| Image asset | `Document.image_assets` key / `ImageAsset.id` | that UUID |
|
||||
| Video bytes | `VideoClip.file_path` | — (always external) |
|
||||
| Video thumbnails | derived from `video_clips` key | §7 |
|
||||
| Waveform | derived from `AudioPoolEntry.pool_index` | §7 |
|
||||
|
||||
## 9. Save semantics
|
||||
|
||||
A conforming writer MUST perform all media, `project.json`, and `meta` writes for a
|
||||
save inside **one** SQLite transaction.
|
||||
|
||||
### 9.1 In-place vs. create
|
||||
|
||||
- If the target path exists **and** is already a SQLite `.beam`, the writer opens it
|
||||
and writes in place. Unchanged packed media MUST NOT be rewritten (§9.3); this is
|
||||
the central performance/crash-safety property, and writers MUST NOT use a
|
||||
copy-to-temp-and-rename flow for in-place saves.
|
||||
- Otherwise (new file, or migrating a legacy ZIP), the writer creates a fresh DB at
|
||||
`<path>.beam.tmp`, writes everything, commits, then atomically `rename`s it over
|
||||
the target.
|
||||
|
||||
### 9.2 Order of operations within the transaction
|
||||
|
||||
1. Audio pool entries → `Audio` (+ `Waveform`) media rows.
|
||||
2. Raster keyframes → `Raster` (+ `RasterProxy`) media rows.
|
||||
3. Video thumbnail packs → `Thumbnail` media rows.
|
||||
4. Image assets → `ImageAsset` media rows.
|
||||
5. **Garbage-collect**: delete every `media` row (and its chunks) whose id is **not**
|
||||
in the set of live ids accumulated in steps 1–4.
|
||||
6. Write `project.json` and the `meta` keys (`version`, `created`, `modified`).
|
||||
7. Commit; then (create path only) rename the temp file over the target.
|
||||
|
||||
### 9.3 Packed vs. referenced decision (audio)
|
||||
|
||||
For each audio pool entry, in order:
|
||||
|
||||
- If a packed row for its `media_id` already exists in the archive, keep it untouched
|
||||
(do not rewrite the bytes) and keep the entry packed.
|
||||
- Else if its external file resolves and exists: store **referenced** if the entry is
|
||||
video audio, *or* the file is `≥ LARGE_MEDIA_THRESHOLD` (2 GiB) and the large-media
|
||||
mode is not `Pack`; otherwise **pack** it (streamed from disk chunk-by-chunk).
|
||||
- Else if it has `embedded_data`: base64-decode and pack it.
|
||||
- Else: leave its references as-is (it will be reported missing on load).
|
||||
|
||||
Raster, image, and thumbnail media follow the same "write if present, else keep the
|
||||
existing row" rule so that paged-out content survives a save without being held in
|
||||
memory.
|
||||
|
||||
## 10. Load semantics
|
||||
|
||||
### 10.1 Dispatch and version checks
|
||||
|
||||
Open the file, read `project.json`, parse `BeamProject`. Reject unless
|
||||
`version == "1.0.0"` (§4.2). The container's `schema_version` is verified on open
|
||||
(`≤` the reader's maximum).
|
||||
|
||||
### 10.2 Audio resolution
|
||||
|
||||
Per pool entry with a `media_id`: look up the media row. If its `codec` is a
|
||||
**streamable** audio codec (`mp3`, `flac`, `ogg`/`oga`, `wav`/`wave`, `aiff`/`aif`,
|
||||
`aac`, `m4a`, `opus`, `alac`, `caf`), the bytes are **streamed lazily** from the blob
|
||||
at playback time (the entry keeps `media_id`, with `embedded_data`/`path` cleared).
|
||||
Otherwise the bytes are read eagerly into `embedded_data`. A precomputed `Waveform`
|
||||
blob, if present, is read into the entry.
|
||||
|
||||
### 10.3 Visual media paging
|
||||
|
||||
- **Raster** full-res pixels are **not** eagerly decoded; each keyframe is flagged
|
||||
for fault-in and its pixels are paged from its `Raster` row on demand. Its
|
||||
`RasterProxy` PNG **is** read and decoded eagerly so cold scrubs show a low-res
|
||||
frame immediately.
|
||||
- **Image assets** are **not** eagerly read; they page from their `ImageAsset` rows
|
||||
on demand.
|
||||
- **Thumbnail** packs are read eagerly per video clip.
|
||||
|
||||
### 10.4 Missing referenced files
|
||||
|
||||
A pool entry that is neither packed nor embedded, whose `relative_path` resolves to a
|
||||
non-existent file, MUST be reported as a missing file (non-fatal). The host
|
||||
application prompts the user to relocate it.
|
||||
|
||||
## 11. Legacy ZIP format (pre-SQLite)
|
||||
|
||||
Files whose magic is not `SQLite format 3\0` are read as a ZIP archive with this
|
||||
internal layout:
|
||||
|
||||
- `project.json` — the same serialized `BeamProject` (`version` must be `"1.0.0"`).
|
||||
- `media/audio/<name>.<ext>` — audio source files. The codec is taken from the
|
||||
extension. **FLAC entries are decoded to PCM `f32` and re-encoded in memory as
|
||||
IEEE-float WAV (WAV format tag 3)** before being base64-embedded; other formats are
|
||||
embedded as-is. The entry's `relative_path` is cleared after extraction.
|
||||
- `media/raster/<uuid>.png` — raster keyframe pixels, named by
|
||||
`RasterKeyframe.media_path`.
|
||||
|
||||
A reader MUST NOT modify a legacy ZIP on open; it loads into memory only. **The next
|
||||
save migrates the project to SQLite** (the save sees a non-SQLite target, so it takes
|
||||
the create-and-rename path and writes a fresh `.beam` database).
|
||||
|
||||
> Known limitation of the legacy reader: raster pixels are loaded only for
|
||||
> **top-level** layers; raster keyframes nested inside groups/clips are not populated
|
||||
> from a ZIP. The SQLite path recurses correctly. Re-saving to SQLite is the remedy.
|
||||
|
||||
## 12. Large-media policy
|
||||
|
||||
- `LARGE_MEDIA_THRESHOLD = 2 GiB`. Files at or above it trigger the packed-vs-
|
||||
referenced decision below; smaller files are always packed (unless video audio,
|
||||
which is always referenced).
|
||||
- `LargeMediaMode` ∈ `{ Ask, Pack, Reference }`:
|
||||
- `Pack` — pack large files into the database.
|
||||
- `Reference` — store large files by external path.
|
||||
- `Ask` (default) — prompt the user on the first large import, then persist their
|
||||
choice; treated as `Reference` at save time until answered.
|
||||
- **The chosen mode is an application preference, not part of the `.beam` file.** It
|
||||
is stored in the editor's `config.json` (`AppConfig.large_media_default`) under the
|
||||
platform config directory, not in the project. Readers/writers of the format need
|
||||
not consult it except to drive the packed/referenced save decision.
|
||||
|
||||
## 13. Conformance summary
|
||||
|
||||
A conforming **reader** MUST:
|
||||
|
||||
1. dispatch on the 16-byte magic (§3);
|
||||
2. reject `schema_version` greater than supported and `version != "1.0.0"` (§4.2);
|
||||
3. resolve packed media via `media`/`media_chunk` (§6.3) and referenced media
|
||||
relative to the file (§6.4), reporting missing referenced files non-fatally;
|
||||
4. compute derived media ids exactly as in §7.
|
||||
|
||||
A conforming **writer** MUST:
|
||||
|
||||
1. produce the four tables of §4 with `schema_version="1"` and `version="1.0.0"`;
|
||||
2. store UUIDs as 16 big-endian bytes and chunk packed media at a uniform size with a
|
||||
remainder final chunk, setting `total_len` correctly (§6.3);
|
||||
3. perform each save in a single transaction and garbage-collect orphaned media (§9),
|
||||
and prefer in-place writes that do not rewrite unchanged packed media.
|
||||
|
||||
## 14. Security considerations
|
||||
|
||||
- **Path traversal:** referenced media and legacy ZIP entries carry filesystem paths.
|
||||
Readers SHOULD resolve relative paths against the project directory and SHOULD
|
||||
reject or sandbox paths that escape it (`..`, absolute paths) when opening
|
||||
untrusted files.
|
||||
- **Resource limits:** `project.json` and packed blobs are attacker-controllable in
|
||||
an untrusted file. Readers SHOULD bound JSON size and avoid loading entire large
|
||||
blobs into memory (stream via §6.3) to resist memory-exhaustion.
|
||||
- **Decoder safety:** audio/image bytes are handed to media decoders (Symphonia,
|
||||
image, FFmpeg); keep those dependencies current, as they parse untrusted input.
|
||||
- **Legacy ZIP bombs:** the ZIP path SHOULD enforce sane decompression-ratio/size
|
||||
limits.
|
||||
|
||||
## 15. Non-normative notes / known quirks
|
||||
|
||||
These reflect the current reference implementation and are flagged for implementers
|
||||
and future spec revisions:
|
||||
|
||||
- `SerializedAudioBackend.sample_rate` is hardcoded to `48000` on save and SHOULD be
|
||||
ignored in favour of `AudioProject.sample_rate` / `AudioPoolEntry.sample_rate`.
|
||||
- `MediaKind::Video` (`1`) is defined but never written; video media is always an
|
||||
external file via `VideoClip.file_path`.
|
||||
- `RasterKeyframe.media_path` is serialized but unused by the SQLite path (vestigial
|
||||
legacy-ZIP linkage).
|
||||
- The reference writer's `SaveSettings` fields `auto_embed_threshold_bytes`,
|
||||
`force_embed_all`, and `force_link_all` are not consulted by the current save path;
|
||||
only the large-media mode is.
|
||||
- The project `version` check is exact-match with no compatibility window; a future
|
||||
revision should define semantic-version tolerance before bumping it.
|
||||
|
||||
## 16. Inspecting a `.beam` file
|
||||
|
||||
Because the container is plain SQLite, any `.beam` (SQLite variant) can be inspected
|
||||
with standard tooling, e.g.:
|
||||
|
||||
```sh
|
||||
sqlite3 project.beam '.tables'
|
||||
sqlite3 project.beam 'SELECT key,value FROM meta;'
|
||||
sqlite3 project.beam 'SELECT hex(id),kind,codec,storage,total_len FROM media;'
|
||||
sqlite3 project.beam 'SELECT data FROM project_json;' | jq .
|
||||
```
|
||||
|
||||
## 17. References
|
||||
|
||||
- Container: `lightningbeam-ui/lightningbeam-core/src/beam_archive.rs`
|
||||
- Save/load orchestration & `BeamProject`: `lightningbeam-ui/lightningbeam-core/src/file_io.rs`
|
||||
- Audio pool entry: `daw-backend/src/audio/pool.rs`
|
||||
- Document / scene tree: `lightningbeam-ui/lightningbeam-core/src/document.rs`
|
||||
- RFC 2119 (requirement keywords), RFC 4122 (UUID), RFC 3339 (timestamps)
|
||||
|
|
@ -0,0 +1,701 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lightningbeam .beam File Inspector
|
||||
|
||||
A command-line tool to inspect .beam project files.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid as uuidlib
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# First 16 bytes of any SQLite 3 database.
|
||||
SQLITE_MAGIC = b"SQLite format 3\x00"
|
||||
|
||||
# media.kind values (see BEAM_FILE_FORMAT.md §6.1).
|
||||
MEDIA_KIND_NAMES = {
|
||||
0: "Audio", 1: "Video", 2: "Raster", 3: "ImageAsset",
|
||||
4: "Waveform", 5: "Thumbnail", 6: "RasterProxy",
|
||||
}
|
||||
# media.storage values (§6.2).
|
||||
MEDIA_STORAGE_NAMES = {0: "Packed", 1: "Referenced"}
|
||||
|
||||
|
||||
def _is_sqlite(path: Path) -> bool:
|
||||
"""True if the file begins with the SQLite 3 header magic."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read(16) == SQLITE_MAGIC
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _human_size(n: int) -> str:
|
||||
f = float(n)
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||
if f < 1024 or unit == "TiB":
|
||||
return f"{f:.0f} {unit}" if unit == "B" else f"{f:.1f} {unit}"
|
||||
f /= 1024
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
class BeamInspector:
|
||||
"""Inspector for .beam project files (SQLite container, or legacy ZIP)."""
|
||||
|
||||
def __init__(self, beam_file: Path):
|
||||
self.beam_file = beam_file
|
||||
self.project_data: Optional[Dict[str, Any]] = None
|
||||
# SQLite (current) vs ZIP (legacy) container.
|
||||
self.is_sqlite = _is_sqlite(beam_file)
|
||||
# Populated for SQLite files by load():
|
||||
self.meta: Dict[str, str] = {}
|
||||
self.media: List[Dict[str, Any]] = [] # rows from the media table
|
||||
self.media_by_id: Dict[str, Dict[str, Any]] = {} # uuid-string -> row
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
"""Open the SQLite container read-only."""
|
||||
uri = self.beam_file.resolve().as_uri() + "?mode=ro"
|
||||
return sqlite3.connect(uri, uri=True)
|
||||
|
||||
def load(self) -> bool:
|
||||
"""Load and parse the .beam file (container-agnostic)."""
|
||||
try:
|
||||
if self.is_sqlite:
|
||||
return self._load_sqlite()
|
||||
return self._load_zip()
|
||||
except Exception as e:
|
||||
print(f"Error loading .beam file: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def _load_zip(self) -> bool:
|
||||
with zipfile.ZipFile(self.beam_file, 'r') as zip_ref:
|
||||
with zip_ref.open('project.json') as f:
|
||||
self.project_data = json.load(f)
|
||||
return True
|
||||
|
||||
def _load_sqlite(self) -> bool:
|
||||
con = self._connect()
|
||||
try:
|
||||
cur = con.cursor()
|
||||
row = cur.execute("SELECT data FROM project_json WHERE id = 0").fetchone()
|
||||
if not row:
|
||||
raise ValueError("archive has no project.json row")
|
||||
self.project_data = json.loads(row[0])
|
||||
|
||||
self.meta = {k: v for (k, v) in cur.execute("SELECT key, value FROM meta")}
|
||||
|
||||
for (idb, kind, codec, storage, ext_path, total_len,
|
||||
channels, sample_rate, width, height) in cur.execute(
|
||||
"SELECT id, kind, codec, storage, ext_path, total_len, "
|
||||
"channels, sample_rate, width, height FROM media"
|
||||
):
|
||||
uid = str(uuidlib.UUID(bytes=bytes(idb)))
|
||||
info = {
|
||||
"uuid": uid,
|
||||
"kind": kind,
|
||||
"codec": codec,
|
||||
"storage": storage,
|
||||
"ext_path": ext_path,
|
||||
"total_len": total_len or 0,
|
||||
"channels": channels,
|
||||
"sample_rate": sample_rate,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
self.media.append(info)
|
||||
self.media_by_id[uid] = info
|
||||
finally:
|
||||
con.close()
|
||||
return True
|
||||
|
||||
def show_info(self):
|
||||
"""Display basic project information."""
|
||||
if not self.project_data:
|
||||
return
|
||||
|
||||
print("=" * 60)
|
||||
print("PROJECT INFORMATION")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"Container: {'SQLite' if self.is_sqlite else 'Legacy ZIP'}")
|
||||
if self.is_sqlite:
|
||||
print(f"Schema Ver: {self.meta.get('schema_version', 'Unknown')}")
|
||||
print(f"Version: {self.project_data.get('version', 'Unknown')}")
|
||||
print(f"Created: {self.project_data.get('created', 'Unknown')}")
|
||||
print(f"Modified: {self.project_data.get('modified', 'Unknown')}")
|
||||
|
||||
ui_state = self.project_data.get('ui_state', {})
|
||||
print(f"\nProject Name: {ui_state.get('name', 'Unnamed')}")
|
||||
print(f"ID: {ui_state.get('id', 'Unknown')}")
|
||||
print(f"Dimensions: {ui_state.get('width', 0):.0f} x {ui_state.get('height', 0):.0f}")
|
||||
print(f"Framerate: {ui_state.get('framerate', 0):.1f} fps")
|
||||
print(f"Duration: {ui_state.get('duration', 0):.2f} seconds")
|
||||
|
||||
bg = ui_state.get('background_color', {})
|
||||
print(f"Background: rgba({bg.get('r', 0)}, {bg.get('g', 0)}, {bg.get('b', 0)}, {bg.get('a', 255)})")
|
||||
|
||||
audio_backend = self.project_data.get('audio_backend', {})
|
||||
print(f"\nSample Rate: {audio_backend.get('sample_rate', 0)} Hz")
|
||||
|
||||
def show_clips(self):
|
||||
"""Display clips information."""
|
||||
if not self.project_data:
|
||||
return
|
||||
|
||||
ui_state = self.project_data.get('ui_state', {})
|
||||
|
||||
vector_clips = ui_state.get('vector_clips', {})
|
||||
video_clips = ui_state.get('video_clips', {})
|
||||
audio_clips = ui_state.get('audio_clips', {})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("CLIPS")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"Vector Clips: {len(vector_clips)}")
|
||||
print(f"Video Clips: {len(video_clips)}")
|
||||
print(f"Audio Clips: {len(audio_clips)}")
|
||||
|
||||
if vector_clips:
|
||||
print("\nVector Clips:")
|
||||
for clip_id, clip in vector_clips.items():
|
||||
print(f" - {clip.get('name', 'Unnamed')} (ID: {clip_id[:8]}...)")
|
||||
|
||||
if video_clips:
|
||||
print("\nVideo Clips:")
|
||||
for clip_id, clip in video_clips.items():
|
||||
print(f" - {clip.get('name', 'Unnamed')} (ID: {clip_id[:8]}...)")
|
||||
|
||||
if audio_clips:
|
||||
print("\nAudio Clips:")
|
||||
for clip_id, clip in audio_clips.items():
|
||||
print(f" - {clip.get('name', 'Unnamed')} (ID: {clip_id[:8]}...)")
|
||||
|
||||
def show_layers(self):
|
||||
"""Display layer hierarchy."""
|
||||
if not self.project_data:
|
||||
return
|
||||
|
||||
ui_state = self.project_data.get('ui_state', {})
|
||||
root = ui_state.get('root', {})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("LAYER HIERARCHY")
|
||||
print("=" * 60)
|
||||
|
||||
def print_layer(layer: Dict[str, Any], indent: int = 0):
|
||||
# Handle case where layer might not be a dictionary
|
||||
if not isinstance(layer, dict):
|
||||
prefix = " " * indent
|
||||
print(f"{prefix}- ERROR: Layer is {type(layer).__name__}, not a dict: {repr(layer)[:50]}")
|
||||
return
|
||||
|
||||
layer_type = layer.get('type', 'Unknown')
|
||||
layer_name = layer.get('name', 'Unnamed')
|
||||
layer_id = layer.get('id', 'Unknown')
|
||||
|
||||
prefix = " " * indent
|
||||
# Handle layer_id that might not be a string
|
||||
id_str = layer_id[:8] + "..." if isinstance(layer_id, str) and len(layer_id) > 8 else str(layer_id)
|
||||
print(f"{prefix}- [{layer_type}] {layer_name} (ID: {id_str})")
|
||||
|
||||
# Recursively print children
|
||||
children = layer.get('children', [])
|
||||
for child in children:
|
||||
print_layer(child, indent + 1)
|
||||
|
||||
print_layer(root)
|
||||
|
||||
def show_tracks(self):
|
||||
"""Display audio tracks information."""
|
||||
if not self.project_data:
|
||||
return
|
||||
|
||||
audio_backend = self.project_data.get('audio_backend', {})
|
||||
project = audio_backend.get('project', {})
|
||||
tracks_dict = project.get('tracks', {})
|
||||
root_tracks = project.get('root_tracks', [])
|
||||
master_track = project.get('master_track', {})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("AUDIO TRACKS")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"Total Tracks: {len(tracks_dict)}")
|
||||
|
||||
# Iterate through root_tracks in order
|
||||
for track_id in root_tracks:
|
||||
track_id_str = str(track_id)
|
||||
if track_id_str not in tracks_dict:
|
||||
print(f"\n Track {track_id}: ERROR - Track ID not found in tracks dict")
|
||||
continue
|
||||
|
||||
track_node = tracks_dict[track_id_str]
|
||||
|
||||
# TrackNode is an enum with variants like {"Audio": {...}} or {"Midi": {...}}
|
||||
if not isinstance(track_node, dict):
|
||||
print(f"\n Track {track_id}: ERROR - Track node is {type(track_node).__name__}, not a dict")
|
||||
continue
|
||||
|
||||
# Extract the variant (Audio, Midi, or Group)
|
||||
if len(track_node) != 1:
|
||||
print(f"\n Track {track_id}: ERROR - Track node has unexpected structure")
|
||||
continue
|
||||
|
||||
track_type, track_data = list(track_node.items())[0]
|
||||
|
||||
if not isinstance(track_data, dict):
|
||||
print(f"\n Track {track_id}: ERROR - Track data is {type(track_data).__name__}, not a dict")
|
||||
continue
|
||||
|
||||
track_name = track_data.get('name', f'Track {track_id}')
|
||||
muted = track_data.get('muted', False)
|
||||
solo = track_data.get('solo', False)
|
||||
volume = track_data.get('volume', 1.0)
|
||||
pan = track_data.get('pan', 0.0)
|
||||
|
||||
status = []
|
||||
if muted:
|
||||
status.append('MUTED')
|
||||
if solo:
|
||||
status.append('SOLO')
|
||||
status_str = f" [{', '.join(status)}]" if status else ""
|
||||
|
||||
print(f"\n Track {track_id}: {track_name}{status_str}")
|
||||
print(f" Type: {track_type}")
|
||||
print(f" Volume: {volume:.2f}")
|
||||
print(f" Pan: {pan:.2f}")
|
||||
|
||||
if track_type == "Midi":
|
||||
print(f" Instrument: {track_data.get('instrument', 'Unknown')}")
|
||||
notes = track_data.get('notes', [])
|
||||
print(f" Notes: {len(notes)}")
|
||||
preset = track_data.get('instrument_graph_preset')
|
||||
if preset:
|
||||
nodes = preset.get('nodes', [])
|
||||
conns = preset.get('connections', [])
|
||||
print(f" Graph: {len(nodes)} nodes, {len(conns)} connections")
|
||||
else:
|
||||
print(f" Graph: (no preset saved)")
|
||||
elif track_type == "Audio":
|
||||
clips = track_data.get('clips', [])
|
||||
print(f" Clips: {len(clips)}")
|
||||
preset = track_data.get('effects_graph_preset')
|
||||
if preset:
|
||||
nodes = preset.get('nodes', [])
|
||||
conns = preset.get('connections', [])
|
||||
print(f" Graph: {len(nodes)} nodes, {len(conns)} connections")
|
||||
else:
|
||||
print(f" Graph: (no preset saved)")
|
||||
|
||||
print(f"\nMaster Track:")
|
||||
print(f" Volume: {master_track.get('volume', 1.0):.2f}")
|
||||
|
||||
def show_graphs(self):
|
||||
"""Display detailed node graph information for all tracks."""
|
||||
if not self.project_data:
|
||||
return
|
||||
|
||||
audio_backend = self.project_data.get('audio_backend', {})
|
||||
project = audio_backend.get('project', {})
|
||||
tracks_dict = project.get('tracks', {})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("NODE GRAPHS")
|
||||
print("=" * 60)
|
||||
|
||||
for track_id_str, track_node in tracks_dict.items():
|
||||
if not isinstance(track_node, dict) or len(track_node) != 1:
|
||||
continue
|
||||
|
||||
track_type, track_data = list(track_node.items())[0]
|
||||
track_name = track_data.get('name', f'Track {track_id_str}')
|
||||
|
||||
# Get the appropriate preset
|
||||
if track_type == "Audio":
|
||||
preset = track_data.get('effects_graph_preset')
|
||||
graph_label = "Effects Graph"
|
||||
elif track_type == "Midi":
|
||||
preset = track_data.get('instrument_graph_preset')
|
||||
graph_label = "Instrument Graph"
|
||||
else:
|
||||
continue
|
||||
|
||||
print(f"\n Track {track_id_str}: {track_name} ({track_type}) - {graph_label}")
|
||||
|
||||
if not preset:
|
||||
print(f" ** NO PRESET SAVED **")
|
||||
continue
|
||||
|
||||
nodes = preset.get('nodes', [])
|
||||
connections = preset.get('connections', [])
|
||||
output_node = preset.get('output_node')
|
||||
midi_targets = preset.get('midi_targets', [])
|
||||
metadata = preset.get('metadata', {})
|
||||
|
||||
if metadata:
|
||||
print(f" Preset Name: {metadata.get('name', 'Unknown')}")
|
||||
|
||||
print(f" Nodes ({len(nodes)}):")
|
||||
for i, node in enumerate(nodes):
|
||||
node_type = node.get('node_type', '?')
|
||||
node_name = node.get('name', node_type)
|
||||
params = node.get('parameters', {})
|
||||
pos_x = node.get('position_x', 0)
|
||||
pos_y = node.get('position_y', 0)
|
||||
|
||||
markers = []
|
||||
if output_node is not None and i == output_node:
|
||||
markers.append("OUTPUT")
|
||||
if i in midi_targets:
|
||||
markers.append("MIDI TARGET")
|
||||
marker_str = f" [{', '.join(markers)}]" if markers else ""
|
||||
|
||||
print(f" [{i}] {node_type}{marker_str} (name={node_name}, pos={pos_x:.0f},{pos_y:.0f})")
|
||||
|
||||
if params:
|
||||
for param_name, param_val in params.items():
|
||||
print(f" {param_name} = {param_val}")
|
||||
|
||||
print(f" Connections ({len(connections)}):")
|
||||
for conn in connections:
|
||||
src = conn.get('from_node', '?')
|
||||
src_port = conn.get('from_output', '?')
|
||||
dst = conn.get('to_node', '?')
|
||||
dst_port = conn.get('to_input', '?')
|
||||
print(f" [{src}]:{src_port} -> [{dst}]:{dst_port}")
|
||||
|
||||
# Also show layer_to_track_map if present
|
||||
track_map = audio_backend.get('layer_to_track_map', {})
|
||||
if track_map:
|
||||
print(f"\n Layer-to-Track Mapping ({len(track_map)} entries):")
|
||||
for layer_id, track_id in track_map.items():
|
||||
print(f" {layer_id[:8]}... -> Track {track_id}")
|
||||
else:
|
||||
print(f"\n Layer-to-Track Mapping: (none saved)")
|
||||
|
||||
def show_audio_pool(self):
|
||||
"""Display audio pool entries."""
|
||||
if not self.project_data:
|
||||
return
|
||||
|
||||
audio_backend = self.project_data.get('audio_backend', {})
|
||||
pool_entries = audio_backend.get('audio_pool_entries', [])
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("AUDIO POOL")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"Total Entries: {len(pool_entries)}")
|
||||
|
||||
for entry in pool_entries:
|
||||
pool_index = entry.get('pool_index', '?')
|
||||
name = entry.get('name', 'Unnamed')
|
||||
relative_path = entry.get('relative_path')
|
||||
media_id = entry.get('media_id')
|
||||
channels = entry.get('channels', 0)
|
||||
sample_rate = entry.get('sample_rate', 0)
|
||||
has_embedded = entry.get('embedded_data') is not None
|
||||
|
||||
# Storage precedence matches the loader (§8.5/§9.3):
|
||||
# packed (media_id) > external (relative_path) > embedded > unresolved.
|
||||
row = self.media_by_id.get(media_id) if media_id else None
|
||||
if media_id:
|
||||
size = f", {_human_size(row['total_len'])}" if row else ""
|
||||
codec = f", {row['codec']}" if row else ""
|
||||
storage_type = f"Packed in DB{codec}{size}"
|
||||
elif relative_path and not self.is_sqlite and relative_path.startswith("media/audio/"):
|
||||
# Legacy ZIP: media/audio/* lives inside the archive, not external.
|
||||
storage_type = "Embedded (in ZIP)"
|
||||
elif relative_path:
|
||||
storage_type = "External reference"
|
||||
elif has_embedded:
|
||||
storage_type = "Embedded (inline base64)"
|
||||
else:
|
||||
storage_type = "Unresolved (missing)"
|
||||
|
||||
print(f"\n [{pool_index}] {name}")
|
||||
if media_id:
|
||||
print(f" Media ID: {media_id}")
|
||||
print(f" Path: {relative_path if relative_path else 'N/A'}")
|
||||
print(f" Storage: {storage_type}")
|
||||
print(f" Channels: {channels}")
|
||||
print(f" Sample Rate: {sample_rate} Hz")
|
||||
|
||||
def show_media(self):
|
||||
"""Display the SQLite media store (the `media` table)."""
|
||||
if not self.is_sqlite:
|
||||
print("\n(media table view applies to SQLite .beam files; "
|
||||
"use --zip for legacy archives)", file=sys.stderr)
|
||||
return
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("MEDIA STORE")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"Total media rows: {len(self.media)}")
|
||||
|
||||
# Summary by kind.
|
||||
by_kind: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for m in self.media:
|
||||
by_kind.setdefault(MEDIA_KIND_NAMES.get(m["kind"], f"Kind {m['kind']}"), []).append(m)
|
||||
if by_kind:
|
||||
print("\nBy kind:")
|
||||
for kind_name, rows in sorted(by_kind.items()):
|
||||
packed = sum(r["total_len"] for r in rows if r["storage"] == 0)
|
||||
print(f" {kind_name:<12} {len(rows):>4} ({_human_size(packed)} packed)")
|
||||
|
||||
print(f"\n{'Kind':<12} {'Storage':<11} {'Codec':<6} {'Size':>10} UUID / details")
|
||||
print("-" * 78)
|
||||
for m in self.media:
|
||||
kind = MEDIA_KIND_NAMES.get(m["kind"], f"Kind {m['kind']}")
|
||||
storage = MEDIA_STORAGE_NAMES.get(m["storage"], f"Stor {m['storage']}")
|
||||
size = _human_size(m["total_len"]) if m["storage"] == 0 else "-"
|
||||
detail = m["uuid"]
|
||||
extra = []
|
||||
if m["channels"] is not None:
|
||||
extra.append(f"{m['channels']}ch@{m['sample_rate']}Hz")
|
||||
if m["width"] is not None:
|
||||
extra.append(f"{m['width']}x{m['height']}")
|
||||
if m["storage"] == 1 and m["ext_path"]:
|
||||
extra.append(f"-> {m['ext_path']}")
|
||||
if extra:
|
||||
detail += " " + " ".join(extra)
|
||||
print(f"{kind:<12} {storage:<11} {m['codec']:<6} {size:>10} {detail}")
|
||||
|
||||
def show_container(self):
|
||||
"""Show whichever container structure applies to this file."""
|
||||
if self.is_sqlite:
|
||||
self.show_media()
|
||||
else:
|
||||
self.show_zip_structure()
|
||||
|
||||
def show_zip_structure(self):
|
||||
"""Display the ZIP file structure."""
|
||||
if self.is_sqlite:
|
||||
print("\n(this is a SQLite .beam; use --media for the media store)",
|
||||
file=sys.stderr)
|
||||
return
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("ZIP ARCHIVE STRUCTURE")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(self.beam_file, 'r') as zip_ref:
|
||||
total_size = 0
|
||||
compressed_size = 0
|
||||
|
||||
print(f"{'File':<40} {'Size':>12} {'Compressed':>12} {'Method':<10}")
|
||||
print("-" * 80)
|
||||
|
||||
for info in zip_ref.infolist():
|
||||
size = info.file_size
|
||||
comp_size = info.compress_size
|
||||
method = "DEFLATE" if info.compress_type == 8 else "STORED" if info.compress_type == 0 else f"Type {info.compress_type}"
|
||||
|
||||
total_size += size
|
||||
compressed_size += comp_size
|
||||
|
||||
print(f"{info.filename:<40} {size:>12,} {comp_size:>12,} {method:<10}")
|
||||
|
||||
print("-" * 80)
|
||||
print(f"{'TOTAL':<40} {total_size:>12,} {compressed_size:>12,}")
|
||||
|
||||
if total_size > 0:
|
||||
ratio = (1 - compressed_size / total_size) * 100
|
||||
print(f"\nCompression Ratio: {ratio:.1f}%")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading ZIP structure: {e}", file=sys.stderr)
|
||||
|
||||
def extract_json(self, output_path: Optional[Path] = None):
|
||||
"""Extract project.json to a file or stdout."""
|
||||
try:
|
||||
if self.is_sqlite:
|
||||
con = self._connect()
|
||||
try:
|
||||
row = con.execute("SELECT data FROM project_json WHERE id = 0").fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
if not row:
|
||||
raise ValueError("archive has no project.json row")
|
||||
json_data = row[0].encode("utf-8") if isinstance(row[0], str) else row[0]
|
||||
else:
|
||||
with zipfile.ZipFile(self.beam_file, 'r') as zip_ref:
|
||||
json_data = zip_ref.read('project.json')
|
||||
|
||||
if output_path:
|
||||
output_path.write_bytes(json_data)
|
||||
print(f"Extracted project.json to: {output_path}")
|
||||
else:
|
||||
data = json.loads(json_data)
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting project.json: {e}", file=sys.stderr)
|
||||
|
||||
def extract_media(self, output_dir: Path):
|
||||
"""Extract all media to a directory.
|
||||
|
||||
SQLite: each packed media row is reassembled from its chunks and written
|
||||
as ``<uuid>.<codec>``; referenced rows are reported, not copied.
|
||||
ZIP (legacy): the ``media/`` entries are extracted preserving paths.
|
||||
"""
|
||||
try:
|
||||
if self.is_sqlite:
|
||||
self._extract_media_sqlite(output_dir)
|
||||
else:
|
||||
self._extract_media_zip(output_dir)
|
||||
except Exception as e:
|
||||
print(f"Error extracting media: {e}", file=sys.stderr)
|
||||
|
||||
def _extract_media_sqlite(self, output_dir: Path):
|
||||
con = self._connect()
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT id, kind, codec, storage, ext_path, total_len FROM media"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
print("No media rows found in archive.")
|
||||
return
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
written = 0
|
||||
for (idb, kind, codec, storage, ext_path, total_len) in rows:
|
||||
uid = str(uuidlib.UUID(bytes=bytes(idb)))
|
||||
kind_name = MEDIA_KIND_NAMES.get(kind, f"kind{kind}")
|
||||
if storage == 1: # Referenced
|
||||
print(f"Referenced (not copied): {uid} [{kind_name}] -> {ext_path}")
|
||||
continue
|
||||
chunks = con.execute(
|
||||
"SELECT bytes FROM media_chunk WHERE media_id = ? ORDER BY chunk_index",
|
||||
(idb,),
|
||||
).fetchall()
|
||||
data = b"".join(bytes(c[0]) for c in chunks)
|
||||
out = output_dir / f"{uid}.{codec}"
|
||||
out.write_bytes(data)
|
||||
print(f"Extracted: {out.name} [{kind_name}] {_human_size(len(data))}")
|
||||
written += 1
|
||||
print(f"\nExtracted {written} packed media item(s) to: {output_dir}")
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def _extract_media_zip(self, output_dir: Path):
|
||||
with zipfile.ZipFile(self.beam_file, 'r') as zip_ref:
|
||||
media_files = [f for f in zip_ref.namelist() if f.startswith('media/')]
|
||||
if not media_files:
|
||||
print("No media files found in archive.")
|
||||
return
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for media_file in media_files:
|
||||
output_path = output_dir / media_file
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zip_ref.open(media_file) as source:
|
||||
output_path.write_bytes(source.read())
|
||||
print(f"Extracted: {media_file}")
|
||||
print(f"\nExtracted {len(media_files)} media file(s) to: {output_dir}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inspect Lightningbeam .beam project files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s project.beam # Show all information
|
||||
%(prog)s project.beam --info # Show only basic info
|
||||
%(prog)s project.beam --tracks # Show only tracks
|
||||
%(prog)s project.beam --media # Show the SQLite media store
|
||||
%(prog)s project.beam --zip # Show legacy ZIP structure
|
||||
%(prog)s project.beam --extract-json # Print project.json to stdout
|
||||
%(prog)s project.beam --extract-json out.json # Save project.json
|
||||
%(prog)s project.beam --extract-media ./media # Extract media files
|
||||
|
||||
Handles both current SQLite .beam files and legacy ZIP .beam files
|
||||
(detected automatically by the file's magic bytes).
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('beam_file', type=Path, help='.beam file to inspect')
|
||||
parser.add_argument('--info', action='store_true', help='Show project information')
|
||||
parser.add_argument('--clips', action='store_true', help='Show clips')
|
||||
parser.add_argument('--layers', action='store_true', help='Show layer hierarchy')
|
||||
parser.add_argument('--tracks', action='store_true', help='Show audio tracks')
|
||||
parser.add_argument('--graphs', action='store_true', help='Show node graph details for all tracks')
|
||||
parser.add_argument('--pool', action='store_true', help='Show audio pool')
|
||||
parser.add_argument('--media', action='store_true', help='Show the SQLite media store')
|
||||
parser.add_argument('--zip', action='store_true', help='Show legacy ZIP structure')
|
||||
parser.add_argument('--extract-json', nargs='?', const=True, metavar='OUTPUT',
|
||||
help='Extract project.json (to file or stdout)')
|
||||
parser.add_argument('--extract-media', type=Path, metavar='DIR',
|
||||
help='Extract media files to directory')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate input file
|
||||
if not args.beam_file.exists():
|
||||
print(f"Error: File not found: {args.beam_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not args.beam_file.suffix == '.beam':
|
||||
print(f"Warning: File does not have .beam extension: {args.beam_file}", file=sys.stderr)
|
||||
|
||||
# Create inspector
|
||||
inspector = BeamInspector(args.beam_file)
|
||||
|
||||
# Handle extract operations (don't need to load project.json)
|
||||
if args.extract_json:
|
||||
if args.extract_json is True:
|
||||
inspector.extract_json()
|
||||
else:
|
||||
inspector.extract_json(Path(args.extract_json))
|
||||
return
|
||||
|
||||
if args.extract_media:
|
||||
inspector.extract_media(args.extract_media)
|
||||
return
|
||||
|
||||
# Load the beam file
|
||||
if not inspector.load():
|
||||
sys.exit(1)
|
||||
|
||||
# If no specific flags, show everything
|
||||
show_all = not any([args.info, args.clips, args.layers, args.tracks,
|
||||
args.graphs, args.pool, args.media, args.zip])
|
||||
|
||||
if show_all or args.info:
|
||||
inspector.show_info()
|
||||
|
||||
if show_all or args.clips:
|
||||
inspector.show_clips()
|
||||
|
||||
if show_all or args.layers:
|
||||
inspector.show_layers()
|
||||
|
||||
if show_all or args.tracks:
|
||||
inspector.show_tracks()
|
||||
|
||||
if show_all or args.graphs:
|
||||
inspector.show_graphs()
|
||||
|
||||
if show_all or args.pool:
|
||||
inspector.show_audio_pool()
|
||||
|
||||
if show_all:
|
||||
# Show whichever container structure applies to this file.
|
||||
inspector.show_container()
|
||||
elif args.media:
|
||||
inspector.show_media()
|
||||
elif args.zip:
|
||||
inspector.show_zip_structure()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
# Lightningbeam .beam File Inspector
|
||||
|
||||
A Python command-line tool to inspect and analyze `.beam` project files.
|
||||
|
||||
Handles **both** container formats automatically (detected by the file's magic
|
||||
bytes): the current **SQLite** `.beam` and the **legacy ZIP** `.beam`. See
|
||||
[BEAM_FILE_FORMAT.md](./BEAM_FILE_FORMAT.md) for the format specification.
|
||||
|
||||
## Features
|
||||
|
||||
- **Project Information**: Container type, schema version, metadata, dimensions, framerate, duration, background color
|
||||
- **Clips Analysis**: List all vector, video, and audio clips
|
||||
- **Layer Hierarchy**: Display the complete layer tree structure
|
||||
- **Audio Tracks**: Show all audio/MIDI tracks with their settings
|
||||
- **Audio Pool**: List all audio files and their storage details
|
||||
- **Media Store** (SQLite): List the `media` table — kinds, storage, codecs, sizes
|
||||
- **ZIP Structure** (legacy): Examine the internal ZIP archive structure and compression
|
||||
- **Extraction**: Extract `project.json` or media files
|
||||
|
||||
## Installation
|
||||
|
||||
The tool requires Python 3.6+ with no external dependencies (uses only the standard
|
||||
library; `sqlite3` ships with Python).
|
||||
|
||||
```bash
|
||||
chmod +x beam_inspector.py
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Show All Information
|
||||
```bash
|
||||
./beam_inspector.py project.beam
|
||||
```
|
||||
|
||||
### Show Specific Sections
|
||||
```bash
|
||||
# Basic project info only
|
||||
./beam_inspector.py project.beam --info
|
||||
|
||||
# Audio tracks only
|
||||
./beam_inspector.py project.beam --tracks
|
||||
|
||||
# Audio pool entries
|
||||
./beam_inspector.py project.beam --pool
|
||||
|
||||
# Layer hierarchy
|
||||
./beam_inspector.py project.beam --layers
|
||||
|
||||
# Clips summary
|
||||
./beam_inspector.py project.beam --clips
|
||||
|
||||
# Media store (SQLite files): the media table — kinds, storage, codecs, sizes
|
||||
./beam_inspector.py project.beam --media
|
||||
|
||||
# ZIP archive structure (legacy ZIP files only)
|
||||
./beam_inspector.py project.beam --zip
|
||||
```
|
||||
|
||||
When run with no section flags, the tool shows everything and automatically
|
||||
picks the media-store view (SQLite) or ZIP-structure view (legacy) for the file.
|
||||
|
||||
### Extract Files
|
||||
```bash
|
||||
# Print project.json to stdout (pretty-printed)
|
||||
./beam_inspector.py project.beam --extract-json
|
||||
|
||||
# Save project.json to a file
|
||||
./beam_inspector.py project.beam --extract-json output.json
|
||||
|
||||
# Extract all media files to a directory
|
||||
./beam_inspector.py project.beam --extract-media ./extracted_media
|
||||
```
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
PROJECT INFORMATION
|
||||
============================================================
|
||||
Container: SQLite
|
||||
Schema Ver: 1
|
||||
Version: 1.0.0
|
||||
Created: 2025-12-01T12:00:00Z
|
||||
Modified: 2025-12-01T12:30:00Z
|
||||
|
||||
Project Name: My Animation
|
||||
ID: 550e8400-e29b-41d4-a716-446655440000
|
||||
Dimensions: 1920 x 1080
|
||||
Framerate: 60.0 fps
|
||||
Duration: 10.00 seconds
|
||||
Background: rgba(255, 255, 255, 255)
|
||||
|
||||
Sample Rate: 48000 Hz
|
||||
|
||||
============================================================
|
||||
AUDIO TRACKS
|
||||
============================================================
|
||||
Total Tracks: 2
|
||||
|
||||
Track 0: Piano [SOLO]
|
||||
Type: Midi
|
||||
Volume: 0.80
|
||||
Pan: 0.00
|
||||
Instrument: Piano
|
||||
Notes: 24
|
||||
|
||||
Track 1: Background Music
|
||||
Type: Audio
|
||||
Volume: 0.60
|
||||
Pan: 0.00
|
||||
Clips: 3
|
||||
|
||||
Master Track:
|
||||
Volume: 1.00
|
||||
|
||||
============================================================
|
||||
AUDIO POOL
|
||||
============================================================
|
||||
Total Entries: 2
|
||||
|
||||
[0] C2.mp3
|
||||
Media ID: e7e555a6-85f1-4faa-bfd4-c6e4b790986a
|
||||
Path: N/A
|
||||
Storage: Packed in DB, mp3, 412.0 KiB
|
||||
Channels: 2
|
||||
Sample Rate: 44100 Hz
|
||||
|
||||
[1] Background.flac
|
||||
Media ID: a18c0e22-1d3b-4c77-9f0a-2b5e6c4d8e10
|
||||
Path: N/A
|
||||
Storage: Packed in DB, flac, 6.4 MiB
|
||||
Channels: 2
|
||||
Sample Rate: 48000 Hz
|
||||
```
|
||||
|
||||
## Understanding the Output
|
||||
|
||||
### Storage Types
|
||||
- **Packed in DB**: Audio bytes are chunked into the SQLite `media` table (the entry's `media_id` resolves to a packed row). Current default for most audio.
|
||||
- **External reference**: File is referenced from the filesystem by `relative_path` (e.g. large media or video audio).
|
||||
- **Embedded (inline base64)**: Bytes stored directly in `project.json` (`embedded_data`) — legacy/fallback.
|
||||
- **Embedded (in ZIP)**: Legacy ZIP files only — bytes stored inside the archive under `media/audio/`.
|
||||
- **Unresolved (missing)**: No packed row, external file, or embedded data — reported as a missing file on load.
|
||||
|
||||
### Track Types
|
||||
- **Audio**: Traditional audio track with clips from the audio pool
|
||||
- **Midi**: MIDI track with note events and virtual instrument
|
||||
|
||||
### Layer Types
|
||||
Based on the `AnyLayer` enum:
|
||||
- **Group**: Container for other layers
|
||||
- **Vector**: Vector graphics layer
|
||||
- **Video**: Video clip layer
|
||||
- **Audio**: Audio waveform layer
|
||||
- **Image**: Raster image layer
|
||||
- **Text**: Text layer
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Combine with Other Tools
|
||||
```bash
|
||||
# Pretty-print and page through JSON
|
||||
./beam_inspector.py project.beam --extract-json | less
|
||||
|
||||
# Search for specific content in JSON
|
||||
./beam_inspector.py project.beam --extract-json | grep "sample_rate"
|
||||
|
||||
# Count total audio files
|
||||
./beam_inspector.py project.beam --pool | grep "^\[" | wc -l
|
||||
|
||||
# Extract and process media files
|
||||
# SQLite: writes flat <uuid>.<codec> files; ZIP: preserves media/ paths
|
||||
./beam_inspector.py project.beam --extract-media /tmp/media
|
||||
ls -lh /tmp/media/
|
||||
```
|
||||
|
||||
### Scripting
|
||||
```python
|
||||
from beam_inspector import BeamInspector
|
||||
from pathlib import Path
|
||||
|
||||
inspector = BeamInspector(Path("project.beam"))
|
||||
if inspector.load():
|
||||
# Access parsed data
|
||||
version = inspector.project_data['version']
|
||||
tracks = inspector.project_data['audio_backend']['project']['tracks']
|
||||
print(f"Found {len(tracks)} tracks in version {version}")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Error loading .beam file"
|
||||
- Ensure the file is a valid SQLite database (current) or ZIP archive (legacy)
|
||||
- Check that `project.json` exists (the `project_json` table for SQLite, or a top-level entry for ZIP)
|
||||
- Verify the JSON is well-formed
|
||||
|
||||
### "File not found"
|
||||
- Provide the full path to the .beam file
|
||||
- Check file permissions
|
||||
|
||||
### Missing media files
|
||||
- External references may point to files that don't exist
|
||||
- Use `--extract-media` to see what's actually in the archive
|
||||
- Check the `relative_path` values in `--pool` output
|
||||
|
||||
## See Also
|
||||
|
||||
- [BEAM_FILE_FORMAT.md](./BEAM_FILE_FORMAT.md) - Complete .beam file format specification
|
||||
- [Lightningbeam Documentation](./README.md) - Main project documentation
|
||||
Loading…
Reference in New Issue