Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions braidio/transforms/_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,12 @@ def episode_timeline(project, episode: Annotation) -> TimelineBreakdown:
structure, _bed = _structure_and_bed(
next((p for p in parents if p.tier == TIER_PRODUCTION_STRUCTURE), None)
)
member_ids = [uuid.UUID(s) for s in episode.body.get("ordered_member_ids", ())]
members = [index[mid] for mid in member_ids if mid in index]
if len(members) != len(member_ids):
missing = [str(m) for m in member_ids if m not in index]
raise ValueError(
f"episode {episode.id}: cannot reconstruct its timeline — members "
f"{missing} are no longer in the graph. Re-weave the project instead."
)
members = _resolve_members(
episode,
index,
what="cannot reconstruct its timeline",
fix="Re-weave the project instead.",
)
durations = [
_member_duration(project, m, config=config, structure=structure)
for m in members
Expand Down Expand Up @@ -541,6 +539,24 @@ def _clip_parents(members, index):
yield require_tier(parents, TIER_AUDIO_CLIP)


def _resolve_members(episode: Annotation, index: dict, *, what: str, fix: str) -> list:
"""The members named by ``episode.body["ordered_member_ids"]``, in order.

Raises if any is absent from ``index``. A timeline is paired to its members
by POSITION (``member_spans``, ``episode_script`` + ``captions.cues_for``,
the picture track), so a member silently skipped would shift every member
after it (braidio#98). ``what`` / ``fix`` word the error for the caller.
"""
member_ids = [uuid.UUID(s) for s in episode.body.get("ordered_member_ids", ())]
missing = [str(m) for m in member_ids if m not in index]
if missing:
raise ValueError(
f"episode {episode.id}: {what} — members {missing} are no longer in the "
f"graph. {fix}"
)
return [index[m] for m in member_ids]


@register_transform(NAME)
class WeaveToEpisode(BaseTransform):
"""All members — renders and scene breaks — (+ weave-config, + the
Expand Down Expand Up @@ -619,8 +635,12 @@ def execute(
cache_hit_savings_usd=0.0,
)
index = graph_index(project)
member_ids = [uuid.UUID(s) for s in skel.body.get("ordered_member_ids", ())]
members = [index[mid] for mid in member_ids if mid in index]
members = _resolve_members(
skel,
index,
what="cannot render",
fix="Re-run the weave so the skeleton is planned over the current graph.",
)

parents = resolve_parents(skel, index)
cfg = require_tier(parents, TIER_WEAVE_CONFIG)
Expand Down
95 changes: 95 additions & 0 deletions tests/test_transforms_episode_members.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""``weave_to_episode.execute`` must not persist a member list its timeline does not match.

The persisted ``timeline`` is built over the members that are *in the graph*,
but ``ordered_member_ids`` names the whole skeleton list. ``member_spans``,
``episode_script`` + ``captions.cues_for`` and the picture track all pair a
member to a timeline beat by POSITION, so one missing member silently shifts
every member after it (braidio#98). The reconstruction path
(``episode_timeline``) already refuses that state; ``execute`` must too.
"""

from __future__ import annotations

import uuid
from pathlib import Path

import pytest

import braidio

from _audio import FixedSource, needs_ffmpeg, tone

pytestmark = [
pytest.mark.skipif(not braidio.HAS_NW, reason="nw (and lacing) not available"),
needs_ffmpeg,
]


@pytest.fixture
def woven(tmp_path, monkeypatch):
"""A woven project: ``(project, episode)`` over three narration beats."""

def fake_narrate(text, out, *, return_cache_status=False, **kw):
p = tone(Path(out), 220, 1.0)
return (p, False) if return_cache_status else p

monkeypatch.setattr(braidio, "narrate", fake_narrate)
project = braidio.Project.init(tmp_path / "proj", title="members")
source = FixedSource(tone(tmp_path / "song.wav", 440, 8.0), start_s=1.0, end_s=3.0)
script = braidio.Script(
title="M",
id_slug="m",
beats=[braidio.Narration(text=t) for t in ("one", "two", "three")],
)
return project, braidio.weave_project(project, script, source=source)


def _execute(project, skeleton):
from falaw import Plan

from braidio.transforms._episode import WeaveToEpisode

return WeaveToEpisode().execute(
project, Plan(calls=()), (skeleton,), use_cache=False
)


def _node_count(project) -> int:
import nw

return sum(1 for _ in nw.iter_all_annotations(project.root))


def test_a_member_missing_at_execute_time_is_refused_not_persisted(woven):
project, episode = woven
ids = list(episode.body["ordered_member_ids"])
ghost = str(uuid.uuid4())
skeleton = episode.model_copy(
update={
"id": uuid.uuid4(),
"body": {**episode.body, "ordered_member_ids": [ids[0], ghost, *ids[1:]]},
}
)
before = _node_count(project)
with pytest.raises(ValueError, match=ghost):
_execute(project, skeleton)
assert _node_count(project) == before # nothing was persisted


def test_a_complete_member_list_keeps_ids_and_timeline_in_step(woven):
project, episode = woven
skeleton = episode.model_copy(update={"id": uuid.uuid4()})
(done,) = _execute(project, skeleton).annotations
assert len(done.body["timeline"]["beats"]) == len(done.body["ordered_member_ids"])


def test_reconstructing_a_timeline_refuses_a_missing_member_too(woven):
from braidio.transforms import episode_timeline

project, episode = woven
ids = list(episode.body["ordered_member_ids"])
ghost = str(uuid.uuid4())
body = {k: v for k, v in episode.body.items() if k != "timeline"}
body["ordered_member_ids"] = [ids[0], ghost, *ids[1:]]
with pytest.raises(ValueError, match=ghost):
episode_timeline(project, episode.model_copy(update={"body": body}))
Loading