Onboard an episode to an ElevenLabs Studio project.
Reads a parsed script JSON and cast configuration, then builds the
from_content_json payload expected by the ElevenLabs Studio Projects
API. Each dialogue line is tagged with the correct voice_id so that
speaker names never appear in TTS text.
Usage:
python XILP004_studio_onboard.py --episode S01E02 --dry-run
python XILP004_studio_onboard.py --episode S01E02
python XILP004_studio_onboard.py --episode S01E02 --quality high
logger
module-attribute
logger = get_logger(__name__)
client
module-attribute
client = ElevenLabs(api_key=get('ELEVENLABS_API_KEY'))
load_episode
load_episode(episode_tag: str, slug: str | None = None)
Load parsed JSON and cast config for episode_tag (e.g. S01E02).
Validates that no cast member has voice_id == "TBD".
Returns:
-
–
Tuple of (parsed_data, cast_data).
Raises:
-
SystemExit
–
If files are missing or a voice_id is TBD.
Source code in src/xil_pipeline/XILP004_studio_onboard.py
| def load_episode(episode_tag: str, slug: str | None = None):
"""Load parsed JSON and cast config for *episode_tag* (e.g. ``S01E02``).
Validates that no cast member has ``voice_id == "TBD"``.
Returns:
Tuple of (parsed_data, cast_data).
Raises:
SystemExit: If files are missing or a voice_id is TBD.
"""
s = slug or resolve_slug()
p = derive_paths(s, episode_tag)
parsed_path = p["parsed"]
cast_path = p["cast"]
if not os.path.exists(parsed_path):
logger.error("Parsed file not found: %s", parsed_path)
sys.exit(1)
if not os.path.exists(cast_path):
logger.error("Cast file not found: %s", cast_path)
sys.exit(1)
with open(parsed_path, encoding="utf-8") as f:
parsed = json.load(f)
with open(cast_path, encoding="utf-8") as f:
cast = json.load(f)
# Validate no TBD voice IDs
tbd_speakers = [
key for key, info in cast["cast"].items()
if info.get("voice_id", "TBD") == "TBD"
]
if tbd_speakers:
logger.error("TBD voice_id for: %s", ', '.join(tbd_speakers))
logger.error(" Assign voice IDs in cast config before onboarding.")
sys.exit(1)
return parsed, cast
|
build_content_json
build_content_json(parsed: dict, cast: dict) -> list[dict]
Transform parsed entries into the from_content_json chapter list.
Mapping rules:
section_header → new chapter (name = section text)
scene_header → block with sub_type: "h2", narrator voice
dialogue → block with sub_type: "p", speaker's voice_id
direction → skipped (SFX/BEAT/AMBIENCE not voiced)
Returns:
-
list[dict]
–
List of chapter dicts ready for json.dumps().
Source code in src/xil_pipeline/XILP004_studio_onboard.py
| def build_content_json(parsed: dict, cast: dict) -> list[dict]:
"""Transform parsed entries into the ``from_content_json`` chapter list.
Mapping rules:
- ``section_header`` → new chapter (``name`` = section text)
- ``scene_header`` → block with ``sub_type: "h2"``, narrator voice
- ``dialogue`` → block with ``sub_type: "p"``, speaker's ``voice_id``
- ``direction`` → **skipped** (SFX/BEAT/AMBIENCE not voiced)
Returns:
List of chapter dicts ready for ``json.dumps()``.
"""
# Determine narrator voice (first Host/Narrator, or first cast member)
narrator_voice = None
for key, info in cast["cast"].items():
if info.get("role") == "Host/Narrator":
narrator_voice = info["voice_id"]
break
if narrator_voice is None:
# Fallback: first cast member
narrator_voice = next(iter(cast["cast"].values()))["voice_id"]
chapters = []
current_chapter = None
for entry in parsed["entries"]:
entry_type = entry["type"]
if entry_type == "section_header":
current_chapter = {
"name": entry["text"],
"blocks": [],
}
chapters.append(current_chapter)
elif entry_type == "scene_header":
if current_chapter is None:
current_chapter = {"name": "Untitled", "blocks": []}
chapters.append(current_chapter)
current_chapter["blocks"].append({
"sub_type": "h2",
"nodes": [
{
"type": "tts_node",
"text": entry["text"],
"voice_id": narrator_voice,
}
],
})
elif entry_type == "dialogue":
if current_chapter is None:
current_chapter = {"name": "Untitled", "blocks": []}
chapters.append(current_chapter)
speaker_key = entry["speaker"]
voice_id = cast["cast"].get(speaker_key, {}).get("voice_id", narrator_voice)
current_chapter["blocks"].append({
"sub_type": "p",
"nodes": [
{
"type": "tts_node",
"text": entry["text"],
"voice_id": voice_id,
}
],
})
# direction entries are skipped
return chapters
|
check_elevenlabs_quota
check_elevenlabs_quota() -> int | None
Display current ElevenLabs API character usage and return remaining.
Source code in src/xil_pipeline/XILP004_studio_onboard.py
| def check_elevenlabs_quota() -> int | None:
"""Display current ElevenLabs API character usage and return remaining."""
try:
user_info = client.user.get()
sub = user_info.subscription
used = sub.character_count
limit = sub.character_limit
remaining = limit - used
logger.info("\n%s", "=" * 40)
logger.info("ELEVENLABS API STATUS:")
logger.info(" Tier: %s", sub.tier.upper())
logger.info(" Usage: %s / %s characters", f"{used:,}", f"{limit:,}")
logger.info(" Remaining: %s", f"{remaining:,}")
logger.info("%s\n", "=" * 40)
return remaining
except ApiError as e:
logger.warning("API Error: Unable to fetch subscription data.")
logger.warning(" Details: %s", e)
return None
|
create_project
create_project(name: str, content_json: list[dict], *, default_voice_id: str, model_id: str = 'eleven_v3', quality: str = 'standard')
Create an ElevenLabs Studio project from content JSON.
Parameters:
-
name
(str)
–
-
content_json
(list[dict])
–
Chapter/block/node structure.
-
default_voice_id
(str)
–
Narrator voice for titles and fallback.
-
model_id
(str, default:
'eleven_v3'
)
–
-
quality
(str, default:
'standard'
)
–
Quality preset (standard/high/ultra/ultra_lossless).
Returns:
-
–
API response with project_id.
Source code in src/xil_pipeline/XILP004_studio_onboard.py
| def create_project(name: str, content_json: list[dict], *,
default_voice_id: str,
model_id: str = "eleven_v3",
quality: str = "standard"):
"""Create an ElevenLabs Studio project from content JSON.
Args:
name: Project name.
content_json: Chapter/block/node structure.
default_voice_id: Narrator voice for titles and fallback.
model_id: TTS model identifier.
quality: Quality preset (standard/high/ultra/ultra_lossless).
Returns:
API response with ``project_id``.
"""
response = client.studio.projects.create(
name=name,
default_title_voice_id=default_voice_id,
default_paragraph_voice_id=default_voice_id,
default_model_id=model_id,
from_content_json=json.dumps(content_json),
quality_preset=quality,
)
return response
|
dry_run
dry_run(chapters: list[dict], cast: dict) -> None
Pretty-print the content structure without calling the API.
Source code in src/xil_pipeline/XILP004_studio_onboard.py
| def dry_run(chapters: list[dict], cast: dict) -> None:
"""Pretty-print the content structure without calling the API."""
# Build reverse map: voice_id → character name
voice_map = {}
for key, info in cast["cast"].items():
voice_map[info["voice_id"]] = info.get("full_name", key)
total_blocks = 0
total_chars = 0
logger.info("\n%s", "=" * 60)
logger.info("STUDIO PROJECT — DRY RUN")
logger.info("%s", "=" * 60)
for chapter in chapters:
logger.info("\n Chapter: %s", chapter['name'])
block_count = len(chapter["blocks"])
total_blocks += block_count
char_count = sum(
len(node["text"])
for block in chapter["blocks"]
for node in block["nodes"]
)
total_chars += char_count
# Show voice assignments in this chapter
voices_used = set()
for block in chapter["blocks"]:
for node in block["nodes"]:
vid = node.get("voice_id")
if vid:
voices_used.add(voice_map.get(vid, vid))
logger.info(" Blocks: %d | Characters: %s", block_count, f"{char_count:,}")
logger.info(" Voices: %s", ', '.join(sorted(voices_used)))
logger.info("\n TOTAL: %d chapters, %d blocks, %s characters", len(chapters), total_blocks, f"{total_chars:,}")
logger.info("%s\n", "=" * 60)
|
get_parser
get_parser() -> argparse.ArgumentParser
Source code in src/xil_pipeline/XILP004_studio_onboard.py
| def get_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="xil-studio-onboard",
description="Onboard an episode to an ElevenLabs Studio project.",
)
tag_group = parser.add_mutually_exclusive_group(required=True)
tag_group.add_argument("--episode", help="Episode tag (e.g. S01E02)")
tag_group.add_argument("--tag", help="Raw tag for non-episodic content (e.g. V01C03, D01)")
parser.add_argument(
"--show", default=None,
help="Show name override (default: from project.json)"
)
parser.add_argument(
"--dry-run", action="store_true",
help="Build and display content JSON without calling the API"
)
parser.add_argument(
"--quality", default="standard",
choices=["standard", "high", "ultra", "ultra_lossless"],
help="Quality preset (default: standard)"
)
parser.add_argument(
"--model", default="eleven_v3",
help="TTS model ID (default: eleven_v3)"
)
return parser
|
main
Source code in src/xil_pipeline/XILP004_studio_onboard.py
| def main():
configure_logging()
with run_banner():
parser = get_parser()
args = parser.parse_args()
if not args.dry_run and not os.environ.get("ELEVENLABS_API_KEY"):
sys.exit("Error: ELEVENLABS_API_KEY environment variable is not set.")
tag = args.episode or args.tag
slug = resolve_slug(args.show)
parsed, cast = load_episode(tag, slug=slug)
chapters = build_content_json(parsed, cast)
if args.dry_run:
dry_run(chapters, cast)
return
# Determine narrator voice for defaults
narrator_voice = None
for info in cast["cast"].values():
if info.get("role") == "Host/Narrator":
narrator_voice = info["voice_id"]
break
if narrator_voice is None:
narrator_voice = next(iter(cast["cast"].values()))["voice_id"]
show = parsed.get("show", "Unknown Show")
title = parsed.get("title", tag)
project_name = f"XILP004 - {show} — {title} ({tag})"
logger.info("Creating Studio project: %s", project_name)
check_elevenlabs_quota()
response = create_project(
name=project_name,
content_json=chapters,
default_voice_id=narrator_voice,
model_id=args.model,
quality=args.quality,
)
logger.info("\nProject created successfully!")
logger.info(" Project ID: %s", response.project.project_id)
|