braidio 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- braidio/__init__.py +242 -0
- braidio/bodies/__init__.py +42 -0
- braidio/bodies/_domain.py +120 -0
- braidio/bodies/_render_nodes.py +107 -0
- braidio/bodies/_tiers.py +43 -0
- braidio/compose.py +67 -0
- braidio/conversation.py +181 -0
- braidio/delivery.py +132 -0
- braidio/formats.py +332 -0
- braidio/kinds.py +12 -0
- braidio/multivoice.py +244 -0
- braidio/music.py +111 -0
- braidio/project.py +26 -0
- braidio/provenance.py +152 -0
- braidio/render.py +228 -0
- braidio/rights.py +197 -0
- braidio/script.py +117 -0
- braidio/sources.py +249 -0
- braidio/tts.py +163 -0
- braidio/weave.py +260 -0
- braidio/weave_config.py +121 -0
- braidio-0.0.2.dist-info/METADATA +168 -0
- braidio-0.0.2.dist-info/RECORD +25 -0
- braidio-0.0.2.dist-info/WHEEL +4 -0
- braidio-0.0.2.dist-info/licenses/LICENSE +21 -0
braidio/multivoice.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Multi-voice narration: cycle a pool of voices across segments (issue #10).
|
|
2
|
+
|
|
3
|
+
When one voice reads flat, variety across voices is a stronger lever than any
|
|
4
|
+
single-voice setting. This renders narration by splitting it into segments
|
|
5
|
+
(sentences) and assigning each a voice drawn from a pool — randomly (seeded, so
|
|
6
|
+
it's reproducible) and avoiding immediate repeats so it doesn't feel like a
|
|
7
|
+
rigid round-robin. Speed is jittered slightly per segment, even within one
|
|
8
|
+
speaker, to break regularity.
|
|
9
|
+
|
|
10
|
+
Two curated pools of **premade** ElevenLabs voices (all production quality,
|
|
11
|
+
deliberately less familiar than "George", 2M/2F in :data:`POOL_4`, wider in
|
|
12
|
+
:data:`POOL_MANY`). For a truly large "many people / interviews" pool, the
|
|
13
|
+
shared Voice Library (thousands) can be tapped, but quality varies there, so
|
|
14
|
+
those need curation — the premade pools below are the safe default.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import random
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from mixing import concatenate_audio
|
|
27
|
+
|
|
28
|
+
from braidio.tts import narrate
|
|
29
|
+
|
|
30
|
+
_MARKUP_RE = re.compile(r"<[^>]*>|\[[^\]]*\]")
|
|
31
|
+
_SENT_SPLIT = re.compile(r"(?<=[.?!])\s+(?=[\"“']?[A-Z])")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Voice:
|
|
36
|
+
"""A pooled narration voice."""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
name: str
|
|
40
|
+
gender: str # "M" | "F"
|
|
41
|
+
accent: str = ""
|
|
42
|
+
note: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# 2M / 2F, mixed accents/registers, all less familiar than "George".
|
|
46
|
+
POOL_4: list[Voice] = [
|
|
47
|
+
Voice("EXAVITQu4vr4xnSDxMaL", "Sarah", "F", "American", "mature, reassuring"),
|
|
48
|
+
Voice("Xb7hH8MSUJpSbSDYk0k2", "Alice", "F", "British", "clear, engaging"),
|
|
49
|
+
Voice("IKne3meq5aSn9XLyUdCD", "Charlie", "M", "Australian", "deep, energetic"),
|
|
50
|
+
Voice("cjVigY5qzO86Huf0OWal", "Eric", "M", "American", "smooth, trustworthy"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# Wider pool for a "lots of different people / interviews" feel (5M / 5F).
|
|
54
|
+
POOL_MANY: list[Voice] = POOL_4 + [
|
|
55
|
+
Voice("cgSgspJ2msm6clMCkdW9", "Jessica", "F", "American", "playful, bright"),
|
|
56
|
+
Voice("XrExE9yKIg1WjnnlVkGX", "Matilda", "F", "American", "professional"),
|
|
57
|
+
Voice("pFZP5JQG7iQjIQuC4Bku", "Lily", "F", "British", "velvety"),
|
|
58
|
+
Voice("N2lVS1w4EtoT3dr4eOWO", "Callum", "M", "American", "husky"),
|
|
59
|
+
Voice("bIHbv24MWmeRgasZH58o", "Will", "M", "American", "relaxed"),
|
|
60
|
+
Voice("pqHfZKP75CvOlQylNhV4", "Bill", "M", "American", "wise, mature"),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
POOLS: dict[str, list[Voice]] = {"four": POOL_4, "many": POOL_MANY}
|
|
64
|
+
|
|
65
|
+
_BASE_SETTINGS = {
|
|
66
|
+
"stability": 0.4,
|
|
67
|
+
"similarity_boost": 0.75,
|
|
68
|
+
"style": 0.3,
|
|
69
|
+
"use_speaker_boost": True,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def strip_markup(text: str) -> str:
|
|
74
|
+
return _MARKUP_RE.sub(" ", text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def split_segments(text: str) -> list[str]:
|
|
78
|
+
"""Split narration into sentence-level segments (markup removed).
|
|
79
|
+
|
|
80
|
+
Splits on sentence-final ``.?!`` (not the ``…`` used for in-thought pacing),
|
|
81
|
+
so connected clauses stay with one speaker.
|
|
82
|
+
"""
|
|
83
|
+
clean = re.sub(r"\s+", " ", strip_markup(text)).strip()
|
|
84
|
+
return [s.strip() for s in _SENT_SPLIT.split(clean) if s.strip()]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def assign_voices(
|
|
88
|
+
n: int, pool: list[Voice], *, seed: int = 0, avoid_repeats: bool = True
|
|
89
|
+
) -> list[Voice]:
|
|
90
|
+
"""Assign a voice to each of ``n`` turns — random, seeded, no immediate
|
|
91
|
+
repeats (so it isn't a rigid round-robin)."""
|
|
92
|
+
rng = random.Random(seed)
|
|
93
|
+
out: list[Voice] = []
|
|
94
|
+
prev: Voice | None = None
|
|
95
|
+
for _ in range(n):
|
|
96
|
+
choices = [v for v in pool if not (avoid_repeats and v is prev)] or pool
|
|
97
|
+
v = rng.choice(choices)
|
|
98
|
+
out.append(v)
|
|
99
|
+
prev = v
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def group_turns(
|
|
104
|
+
segments: list[str], *, min_turn: int = 1, max_turn: int = 1, seed: int = 0
|
|
105
|
+
) -> list[str]:
|
|
106
|
+
"""Group consecutive segments into *turns* of ``min_turn..max_turn`` segments.
|
|
107
|
+
|
|
108
|
+
A turn is what one voice speaks before the next takes over. Bigger turns =
|
|
109
|
+
each speaker talks longer (fewer switches). Each turn's segments are joined
|
|
110
|
+
into one utterance so prosody is continuous within a speaker. Turn sizes are
|
|
111
|
+
seeded-random within the range.
|
|
112
|
+
"""
|
|
113
|
+
if min_turn < 1 or max_turn < min_turn:
|
|
114
|
+
raise ValueError("require 1 <= min_turn <= max_turn")
|
|
115
|
+
rng = random.Random(seed * 17 + 3)
|
|
116
|
+
turns: list[str] = []
|
|
117
|
+
i = 0
|
|
118
|
+
while i < len(segments):
|
|
119
|
+
k = rng.randint(min_turn, max_turn)
|
|
120
|
+
turns.append(" ".join(segments[i : i + k]))
|
|
121
|
+
i += k
|
|
122
|
+
return turns
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _loudnorm(
|
|
126
|
+
src: Path, dst: Path, *, target_lufs: float = -16.0, true_peak: float = -1.5
|
|
127
|
+
) -> Path:
|
|
128
|
+
if shutil.which("ffmpeg") is None:
|
|
129
|
+
raise RuntimeError("ffmpeg not found on PATH.")
|
|
130
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
subprocess.run(
|
|
132
|
+
[
|
|
133
|
+
"ffmpeg",
|
|
134
|
+
"-y",
|
|
135
|
+
"-i",
|
|
136
|
+
str(src),
|
|
137
|
+
"-af",
|
|
138
|
+
f"loudnorm=I={target_lufs}:TP={true_peak}:LRA=11",
|
|
139
|
+
"-ar",
|
|
140
|
+
"44100",
|
|
141
|
+
str(dst),
|
|
142
|
+
],
|
|
143
|
+
check=True,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
)
|
|
146
|
+
return dst
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def render_multivoice(
|
|
150
|
+
segments: list[str],
|
|
151
|
+
pool: list[Voice],
|
|
152
|
+
*,
|
|
153
|
+
out_path: str | Path,
|
|
154
|
+
api_key: str | None = None,
|
|
155
|
+
work_dir: str | Path = "data/tts/multivoice",
|
|
156
|
+
seed: int = 7,
|
|
157
|
+
min_turn: int = 2,
|
|
158
|
+
max_turn: int = 4,
|
|
159
|
+
avoid_immediate_repeat: bool = True,
|
|
160
|
+
model_id: str = "eleven_multilingual_v2",
|
|
161
|
+
base_settings: dict | None = None,
|
|
162
|
+
speed_base: float = 1.0,
|
|
163
|
+
speed_jitter: float = 0.04,
|
|
164
|
+
crossfade_s: float = 0.1,
|
|
165
|
+
gap_s: float = 0.0,
|
|
166
|
+
target_lufs: float = -16.0,
|
|
167
|
+
) -> list[tuple[Voice, str]]:
|
|
168
|
+
"""Render ``segments`` cycling ``pool`` → ``out_path``.
|
|
169
|
+
|
|
170
|
+
Segments are first grouped into *turns* of ``min_turn..max_turn`` segments
|
|
171
|
+
(bigger = each voice talks longer). One voice per turn, no immediate repeat,
|
|
172
|
+
with a jittered speed even within a speaker. ``gap_s`` inserts silence
|
|
173
|
+
between turns (0 = none). Returns ``[(voice, turn_text), …]`` for reporting.
|
|
174
|
+
|
|
175
|
+
``api_key`` is an optional per-request ElevenLabs key threaded to every
|
|
176
|
+
:func:`braidio.tts.narrate` call; ``None`` (default) keeps the
|
|
177
|
+
``$ELEVENLABS_API_KEY`` fallback.
|
|
178
|
+
|
|
179
|
+
NOTE: overlapping/interrupting speakers and clip ducking are separate,
|
|
180
|
+
upcoming parameters (tracked as issues) — this renders turns sequentially.
|
|
181
|
+
"""
|
|
182
|
+
settings = dict(base_settings if base_settings is not None else _BASE_SETTINGS)
|
|
183
|
+
turns = group_turns(segments, min_turn=min_turn, max_turn=max_turn, seed=seed)
|
|
184
|
+
rng = random.Random(seed * 31 + 1)
|
|
185
|
+
voices = assign_voices(
|
|
186
|
+
len(turns), pool, seed=seed, avoid_repeats=avoid_immediate_repeat
|
|
187
|
+
)
|
|
188
|
+
work = Path(work_dir)
|
|
189
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
parts: list[Path] = []
|
|
191
|
+
for i, (turn, v) in enumerate(zip(turns, voices)):
|
|
192
|
+
speed = round(speed_base + rng.uniform(-speed_jitter, speed_jitter), 3)
|
|
193
|
+
raw = narrate(
|
|
194
|
+
turn,
|
|
195
|
+
work / f"turn{i:03d}-{v.name}.mp3",
|
|
196
|
+
api_key=api_key,
|
|
197
|
+
voice_id=v.id,
|
|
198
|
+
model_id=model_id,
|
|
199
|
+
voice_settings={**settings, "speed": speed},
|
|
200
|
+
)
|
|
201
|
+
parts.append(_loudnorm(raw, work / f"norm{i:03d}.mp3", target_lufs=target_lufs))
|
|
202
|
+
out = Path(out_path)
|
|
203
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
if gap_s > 0:
|
|
205
|
+
_concat_with_gaps(parts, out, gap_s=gap_s)
|
|
206
|
+
else:
|
|
207
|
+
concatenate_audio(
|
|
208
|
+
*[str(p) for p in parts], output=str(out), crossfade=crossfade_s
|
|
209
|
+
)
|
|
210
|
+
return list(zip(voices, turns))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _concat_with_gaps(parts: list[Path], out: Path, *, gap_s: float) -> Path:
|
|
214
|
+
"""Concatenate audio parts with ``gap_s`` seconds of silence between them."""
|
|
215
|
+
from mixing import Audio # local import; audio object model
|
|
216
|
+
|
|
217
|
+
silence = None
|
|
218
|
+
pieces: list[str] = []
|
|
219
|
+
for p in parts:
|
|
220
|
+
pieces.append(str(p))
|
|
221
|
+
# Build via ffmpeg: create a silence file and interleave.
|
|
222
|
+
sil = out.parent / "_gap.mp3"
|
|
223
|
+
subprocess.run(
|
|
224
|
+
[
|
|
225
|
+
"ffmpeg",
|
|
226
|
+
"-y",
|
|
227
|
+
"-f",
|
|
228
|
+
"lavfi",
|
|
229
|
+
"-i",
|
|
230
|
+
f"anullsrc=r=44100:cl=stereo",
|
|
231
|
+
"-t",
|
|
232
|
+
f"{gap_s:.3f}",
|
|
233
|
+
str(sil),
|
|
234
|
+
],
|
|
235
|
+
check=True,
|
|
236
|
+
capture_output=True,
|
|
237
|
+
)
|
|
238
|
+
interleaved: list[str] = []
|
|
239
|
+
for i, p in enumerate(pieces):
|
|
240
|
+
if i:
|
|
241
|
+
interleaved.append(str(sil))
|
|
242
|
+
interleaved.append(p)
|
|
243
|
+
concatenate_audio(*interleaved, output=str(out), crossfade=0.0)
|
|
244
|
+
return out
|
braidio/music.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Music bed — an instrumental underscore laid under the whole production, ducked.
|
|
2
|
+
|
|
3
|
+
The bed is the spanning generalization of a per-clip ``under`` placement: instead
|
|
4
|
+
of one clip beneath one talk beat, an app-supplied **instrumental** asset runs
|
|
5
|
+
beneath the entire timeline at reduced gain, entering after speech onset and
|
|
6
|
+
fading at the ends (the conventions from
|
|
7
|
+
``docs/research/commentary-formats-and-styles.md``: bed 10–15 dB under voice,
|
|
8
|
+
~1.5s fade-in, *posted* after the start; beds must be instrumental).
|
|
9
|
+
|
|
10
|
+
braidio ships **no music** — the caller supplies the asset (a licensed/owned
|
|
11
|
+
instrumental). A :class:`~braidio.formats.Format`'s ``music_bed`` *intensity*
|
|
12
|
+
(continuous / light / sparse / none) picks a sensible gain via
|
|
13
|
+
:data:`BED_GAIN_BY_INTENSITY`; :func:`render_format` builds the bed for you when
|
|
14
|
+
given a ``bed_asset``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# Bed gain (dB under the mixed voice) by a Format's music_bed intensity.
|
|
25
|
+
# None means "no bed" for that intensity.
|
|
26
|
+
BED_GAIN_BY_INTENSITY: dict[str, float | None] = {
|
|
27
|
+
"continuous": -20.0,
|
|
28
|
+
"light": -24.0,
|
|
29
|
+
"sparse": -28.0,
|
|
30
|
+
"none": None,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class MusicBed:
|
|
36
|
+
"""An instrumental underscore spanning the production, mixed under the talk.
|
|
37
|
+
|
|
38
|
+
``asset_path`` is an app-supplied instrumental. ``gain_db`` sets how far under
|
|
39
|
+
the voice it sits; ``lead_in_s`` posts the bed *after* speech onset so its
|
|
40
|
+
entrance feels motivated; ``start_s`` is the in-point into the asset;
|
|
41
|
+
``loop`` repeats the asset to cover a timeline longer than it.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
asset_path: str
|
|
45
|
+
gain_db: float = -22.0
|
|
46
|
+
fade_in_s: float = 1.5
|
|
47
|
+
fade_out_s: float = 2.0
|
|
48
|
+
lead_in_s: float = 2.0
|
|
49
|
+
start_s: float = 0.0
|
|
50
|
+
loop: bool = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def bed_for_intensity(asset_path: str, intensity: str, **overrides) -> MusicBed | None:
|
|
54
|
+
"""Build a :class:`MusicBed` at the gain for a Format ``music_bed`` intensity.
|
|
55
|
+
|
|
56
|
+
Returns ``None`` for ``"none"`` (or an unknown intensity), so callers can do
|
|
57
|
+
``bed = bed_for_intensity(asset, fmt.music_bed)`` and skip when falsy.
|
|
58
|
+
"""
|
|
59
|
+
gain = BED_GAIN_BY_INTENSITY.get(intensity)
|
|
60
|
+
if gain is None:
|
|
61
|
+
return None
|
|
62
|
+
return MusicBed(asset_path=asset_path, gain_db=gain, **overrides)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _require_ffmpeg() -> None:
|
|
66
|
+
if shutil.which("ffmpeg") is None:
|
|
67
|
+
raise RuntimeError("ffmpeg not found on PATH (brew install ffmpeg).")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def prepare_bed(
|
|
71
|
+
bed: MusicBed, target_s: float, out_path: str | Path, *, sample_rate: int = 44100
|
|
72
|
+
) -> Path:
|
|
73
|
+
"""Render ``bed`` to a ready-to-mix underscore of length ``target_s - lead_in_s``.
|
|
74
|
+
|
|
75
|
+
Seeks to ``bed.start_s`` (looping if needed), trims to the covered length, and
|
|
76
|
+
bakes in fades + ``gain_db`` + stereo. The caller mixes the result delayed by
|
|
77
|
+
``bed.lead_in_s``. Returns ``out_path``.
|
|
78
|
+
"""
|
|
79
|
+
_require_ffmpeg()
|
|
80
|
+
length = max(0.05, target_s - bed.lead_in_s)
|
|
81
|
+
fo_start = max(0.0, length - bed.fade_out_s)
|
|
82
|
+
out = Path(out_path)
|
|
83
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
pre_input: list[str] = []
|
|
85
|
+
if bed.loop:
|
|
86
|
+
pre_input += ["-stream_loop", "-1"]
|
|
87
|
+
if bed.start_s > 0:
|
|
88
|
+
pre_input += ["-ss", f"{bed.start_s:.3f}"]
|
|
89
|
+
af = (
|
|
90
|
+
f"afade=t=in:st=0:d={bed.fade_in_s},"
|
|
91
|
+
f"afade=t=out:st={fo_start:.3f}:d={bed.fade_out_s},"
|
|
92
|
+
f"volume={bed.gain_db}dB,"
|
|
93
|
+
f"aformat=sample_rates={sample_rate}:channel_layouts=stereo"
|
|
94
|
+
)
|
|
95
|
+
subprocess.run(
|
|
96
|
+
[
|
|
97
|
+
"ffmpeg",
|
|
98
|
+
"-y",
|
|
99
|
+
*pre_input,
|
|
100
|
+
"-i",
|
|
101
|
+
str(bed.asset_path),
|
|
102
|
+
"-t",
|
|
103
|
+
f"{length:.3f}",
|
|
104
|
+
"-af",
|
|
105
|
+
af,
|
|
106
|
+
str(out),
|
|
107
|
+
],
|
|
108
|
+
check=True,
|
|
109
|
+
capture_output=True,
|
|
110
|
+
)
|
|
111
|
+
return out
|
braidio/project.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""braidio as an nw app: a :class:`nw.Project` for commentary-weave productions.
|
|
2
|
+
|
|
3
|
+
Subclassing ``nw.Project`` inherits the whole folder facade + lacing graph +
|
|
4
|
+
freshness (``stale_after``) machinery; braidio adds its production kind and its
|
|
5
|
+
domain/render body-schema vocabulary (registered on import of
|
|
6
|
+
:mod:`braidio.bodies`). This module requires ``nw``; it is imported lazily by
|
|
7
|
+
``braidio/__init__`` only when ``nw`` is available.
|
|
8
|
+
|
|
9
|
+
The costed ``plan``/``execute`` Transform pipeline (each transform delegating to
|
|
10
|
+
braidio's functional core for the actual audio, and recording provenance via
|
|
11
|
+
:mod:`braidio.provenance`) is the next increment — see the design doc and
|
|
12
|
+
nw#9 (generalizing nw's shot/mp4 render seam).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import nw
|
|
18
|
+
|
|
19
|
+
from braidio import bodies as _bodies # noqa: F401 (import registers schemas)
|
|
20
|
+
from braidio.kinds import WeaveKind
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Project(nw.Project):
|
|
24
|
+
"""An nw project for a braidio commentary-weave production."""
|
|
25
|
+
|
|
26
|
+
KIND: WeaveKind = WeaveKind.COMMENTARY_WEAVE
|
braidio/provenance.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Record render choices as linked artifacts → trace + partial re-render.
|
|
2
|
+
|
|
3
|
+
A render is a *projection* of the graph, so the render **choices** belong in the
|
|
4
|
+
graph too. :func:`record_render` writes the render-provenance nodes
|
|
5
|
+
(weave-config, voice-assignment, narration-render, segment-extraction,
|
|
6
|
+
episode-render) with ``was_derived_from`` edges to their inputs.
|
|
7
|
+
:func:`stale_after` then returns exactly the nodes downstream of a change — the
|
|
8
|
+
partial-re-render frontier (a memoized build DAG for audio; mirrors nw's
|
|
9
|
+
``stale_after``). See the design doc + nw's render-provenance rationale.
|
|
10
|
+
|
|
11
|
+
Requires ``lacing``; imported lazily by ``braidio/__init__`` only when present.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from uuid import UUID, uuid4
|
|
17
|
+
|
|
18
|
+
from lacing import Annotation, NodeRef, Provenance, RationalTime, TimeInterval
|
|
19
|
+
|
|
20
|
+
from braidio.bodies import register_tiers
|
|
21
|
+
from braidio.bodies._render_nodes import (
|
|
22
|
+
EPISODE_RENDER_V1,
|
|
23
|
+
NARRATION_RENDER_V1,
|
|
24
|
+
SEGMENT_EXTRACTION_V1,
|
|
25
|
+
VOICE_ASSIGNMENT_V1,
|
|
26
|
+
WEAVE_CONFIG_V1,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_RATE = 1000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _node(store, tier: str, uri: str, body: dict, *, derived_from=()) -> UUID:
|
|
33
|
+
"""Add a render node to ``store`` with provenance; return its id."""
|
|
34
|
+
aid = uuid4()
|
|
35
|
+
store.add(
|
|
36
|
+
Annotation(
|
|
37
|
+
id=aid,
|
|
38
|
+
tier=tier,
|
|
39
|
+
reference=NodeRef(
|
|
40
|
+
scene_path=f"{tier}/{aid}",
|
|
41
|
+
interval=TimeInterval.from_seconds(0.0, 1.0, rate=_RATE),
|
|
42
|
+
),
|
|
43
|
+
body=body,
|
|
44
|
+
body_schema_uri=uri,
|
|
45
|
+
provenance=Provenance(
|
|
46
|
+
was_generated_by="braidio:render",
|
|
47
|
+
was_attributed_to="braidio",
|
|
48
|
+
was_derived_from=list(derived_from),
|
|
49
|
+
generated_at_time=RationalTime.now(rate=_RATE),
|
|
50
|
+
activity="derive",
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return aid
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def record_render(
|
|
58
|
+
store, *, weave_config: dict, beats: list[dict], profile: str = "personal"
|
|
59
|
+
) -> dict:
|
|
60
|
+
"""Write the provenance graph for one render into ``store``.
|
|
61
|
+
|
|
62
|
+
``beats`` is an ordered list of dicts, each either::
|
|
63
|
+
|
|
64
|
+
{"kind": "narration", "source_id": <UUID of the beat/commentary node>,
|
|
65
|
+
"cache_key": str, "voice_id": str, "pool_label": str, "seed": int,
|
|
66
|
+
"duration_s": float, "artifact_id": str | None}
|
|
67
|
+
{"kind": "segment", "source_id": <UUID of the source-media/clip node>,
|
|
68
|
+
"cache_key": str, "start_s": float, "end_s": float, "artifact_id": str | None}
|
|
69
|
+
|
|
70
|
+
Returns ``{"config": UUID, "members": [UUID,…], "episode": UUID}``.
|
|
71
|
+
"""
|
|
72
|
+
register_tiers(store) # idempotent
|
|
73
|
+
cfg = _node(store, "weave-configs", WEAVE_CONFIG_V1, {"config": weave_config})
|
|
74
|
+
members: list[UUID] = []
|
|
75
|
+
for b in beats:
|
|
76
|
+
src = b["source_id"]
|
|
77
|
+
if b["kind"] == "narration":
|
|
78
|
+
va = _node(
|
|
79
|
+
store,
|
|
80
|
+
"voice-assignments",
|
|
81
|
+
VOICE_ASSIGNMENT_V1,
|
|
82
|
+
{
|
|
83
|
+
"voice_id": b.get("voice_id", ""),
|
|
84
|
+
"pool_label": b.get("pool_label", "single"),
|
|
85
|
+
"seed": int(b.get("seed", 0)),
|
|
86
|
+
},
|
|
87
|
+
derived_from=[
|
|
88
|
+
src,
|
|
89
|
+
cfg,
|
|
90
|
+
], # depends on the beat AND the config (pool/seed)
|
|
91
|
+
)
|
|
92
|
+
nid = _node(
|
|
93
|
+
store,
|
|
94
|
+
"narration-renders",
|
|
95
|
+
NARRATION_RENDER_V1,
|
|
96
|
+
{
|
|
97
|
+
"cache_key": b["cache_key"],
|
|
98
|
+
"artifact_id": b.get("artifact_id"),
|
|
99
|
+
"duration_s": float(b.get("duration_s", 0.0)),
|
|
100
|
+
},
|
|
101
|
+
derived_from=[src, va, cfg],
|
|
102
|
+
)
|
|
103
|
+
members.append(nid)
|
|
104
|
+
else: # segment
|
|
105
|
+
sid = _node(
|
|
106
|
+
store,
|
|
107
|
+
"segment-extractions",
|
|
108
|
+
SEGMENT_EXTRACTION_V1,
|
|
109
|
+
{
|
|
110
|
+
"cache_key": b["cache_key"],
|
|
111
|
+
"start_s": float(b.get("start_s", 0.0)),
|
|
112
|
+
"end_s": float(b.get("end_s", 1.0)),
|
|
113
|
+
"artifact_id": b.get("artifact_id"),
|
|
114
|
+
},
|
|
115
|
+
derived_from=[src, cfg],
|
|
116
|
+
)
|
|
117
|
+
members.append(sid)
|
|
118
|
+
episode = _node(
|
|
119
|
+
store,
|
|
120
|
+
"episode-renders",
|
|
121
|
+
EPISODE_RENDER_V1,
|
|
122
|
+
{
|
|
123
|
+
"profile": profile,
|
|
124
|
+
"ordered_member_ids": tuple(str(m) for m in members),
|
|
125
|
+
"artifact_id": None,
|
|
126
|
+
"duration_s": 0.0,
|
|
127
|
+
},
|
|
128
|
+
derived_from=[*members, cfg],
|
|
129
|
+
)
|
|
130
|
+
return {"config": cfg, "members": members, "episode": episode}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def descendants_of(store, changed_id: UUID) -> set[UUID]:
|
|
134
|
+
"""Every annotation transitively derived from ``changed_id`` (via
|
|
135
|
+
``provenance.was_derived_from``). Excludes ``changed_id`` itself."""
|
|
136
|
+
children: dict[UUID, list[UUID]] = {}
|
|
137
|
+
for a in store.all():
|
|
138
|
+
for parent in a.provenance.was_derived_from:
|
|
139
|
+
children.setdefault(parent, []).append(a.id)
|
|
140
|
+
out: set[UUID] = set()
|
|
141
|
+
stack = [changed_id]
|
|
142
|
+
while stack:
|
|
143
|
+
cur = stack.pop()
|
|
144
|
+
for child in children.get(cur, []):
|
|
145
|
+
if child not in out:
|
|
146
|
+
out.add(child)
|
|
147
|
+
stack.append(child)
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# The partial-re-render frontier of a change (mirrors nw.stale_after semantics).
|
|
152
|
+
stale_after = descendants_of
|