Skip to content
Open
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@
<img src="public/podcli-badge.png" height="72" alt="podcli" />
</p>

> [!NOTE]
> **This is a maintained product fork of [nmbrthirteen/podcli](https://github.com/nmbrthirteen/podcli).** It preserves Podcli's local-first engine and CLI while extending Studio for a simpler podcast-production workflow. Upstream changes are reviewed and merged regularly to limit drift.

### What this fork adds

- One-command local Studio launch with `podclip`
- Local silence detection, review, and removal before editing
- Full-episode YouTube workflow with a large 16:9 preview and export
- Live caption and logo previews with placement and font-size controls
- Format-aware captions, including single-line captions for YouTube
- Readable full-episode and per-clip transcripts with one-click copying

Launch the Studio from any directory with `podclip`.

<p align="center">
<strong>Open-source AI podcast clipper.</strong><br/>
Turn a long episode into short clips with face tracking and burned-in captions. Drive it from the CLI, a web studio, or your coding agent.
Expand Down
48 changes: 48 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ def handle_create_clip(task_id: str, params: dict):
start_second=params["start_second"],
end_second=params["end_second"],
caption_style=params.get("caption_style", "hormozi"),
caption_position=params.get("caption_position", "auto"),
caption_font_scale=params.get("caption_font_scale", 100),
logo_position=params.get("logo_position", "top-left"),
crop_strategy=params.get("crop_strategy", "face"),
format=params.get("format", "vertical"),
crop_keyframes=params.get("crop_keyframes"),
Expand Down Expand Up @@ -223,6 +226,9 @@ def render_one(i: int, clip: dict) -> dict:
start_second=clip["start_second"],
end_second=clip["end_second"],
caption_style=clip.get("caption_style", "hormozi"),
caption_position=clip.get("caption_position", params.get("caption_position", "auto")),
caption_font_scale=clip.get("caption_font_scale", params.get("caption_font_scale", 100)),
logo_position=clip.get("logo_position", params.get("logo_position", "top-left")),
crop_strategy=clip.get("crop_strategy", "face"),
format=clip.get("format", params.get("format", "vertical")),
transcript_words=params.get("transcript_words", []),
Expand Down Expand Up @@ -876,6 +882,46 @@ def handle_manage_config(task_id: str, params: dict):
emit_result(task_id, "error", error=str(e))


def handle_analyze_silence(task_id: str, params: dict):
"""Analyze a full episode locally and return a conservative cut plan."""
from services.silence_removal import analyze_silence

try:
result = analyze_silence(
video_path=params.get("video_path", ""),
transcript_words=params.get("transcript_words") or [],
threshold=float(params.get("threshold", 0.5)),
min_silence_seconds=float(params.get("min_silence_seconds", 0.65)),
padding_seconds=float(params.get("padding_seconds", 0.12)),
progress_callback=lambda pct, msg: emit_progress(
task_id, "silence_analysis", pct, msg
),
)
emit_result(task_id, "success", data=result)
except (FileNotFoundError, RuntimeError, ValueError) as e:
emit_result(task_id, "error", error=str(e))


def handle_render_silence_removed(task_id: str, params: dict):
"""Render the approved local cut plan and remap transcript timestamps."""
from config.paths import paths
from services.silence_removal import render_silence_removed

try:
result = render_silence_removed(
video_path=params.get("video_path", ""),
keep_segments=params.get("keep_segments") or [],
transcript=params.get("transcript") or {},
output_dir=params.get("output_dir") or paths["output"],
progress_callback=lambda pct, msg: emit_progress(
task_id, "silence_render", pct, msg
),
)
emit_result(task_id, "success", data=result)
except (FileNotFoundError, RuntimeError, ValueError) as e:
emit_result(task_id, "error", error=str(e))


def handle_run_integration_tool(task_id: str, params: dict):
from services.integrations import IntegrationRegistry, IntegrationsManager

Expand Down Expand Up @@ -932,6 +978,8 @@ def handle_run_integration_tool(task_id: str, params: dict):
"manage_integrations": handle_manage_integrations,
"run_integration_tool": handle_run_integration_tool,
"manage_config": handle_manage_config,
"analyze_silence": handle_analyze_silence,
"render_silence_removed": handle_render_silence_removed,
}


Expand Down
9 changes: 8 additions & 1 deletion backend/services/caption_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ def render_captions(
caption_style: str,
output_path: str,
time_offset: float = 0.0,
caption_position: str = "auto",
caption_font_scale: int = 100,
) -> str:
"""
Generate an ASS subtitle file from word-level timestamps.
Expand All @@ -113,7 +115,12 @@ def render_captions(
f.write(generate_ass_header(get_style(caption_style)))
return output_path

style = get_style(caption_style)
style = dict(get_style(caption_style))
scale = max(60, min(160, int(caption_font_scale))) / 100
style["font_size"] = round(style["font_size"] * scale)
position_margins = {"upper": 760, "center": 480, "lower": 220}
if caption_position in position_margins:
style["margin_v"] = position_margins[caption_position]

if caption_style == "hormozi":
content = _render_hormozi(words, style, time_offset)
Expand Down
9 changes: 7 additions & 2 deletions backend/services/captions_burn.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def burn_captions(
logo_height: int = 80,
logo_margin_x: int = 30,
logo_margin_y: int = 40,
logo_position: str = "top-left",
) -> str:
"""Burn ASS subtitles into the video.

Expand Down Expand Up @@ -99,9 +100,13 @@ def burn_captions(
logo_idx = input_idx
input_idx += 1
filter_parts.append(f"[{logo_idx}:v]scale=-1:{logo_height}[logo]")
filter_parts.append(
f"[{current_label}][logo]overlay={logo_margin_x}:{logo_margin_y}[withlogo]"
logo_x = (
str(logo_margin_x) if logo_position.endswith("-left")
else f"main_w-overlay_w-{logo_margin_x}" if logo_position.endswith("-right")
else "(main_w-overlay_w)/2"
)
logo_y = str(logo_margin_y) if logo_position.startswith("top-") else f"main_h-overlay_h-{logo_margin_y}"
filter_parts.append(f"[{current_label}][logo]overlay={logo_x}:{logo_y}[withlogo]")
current_label = "withlogo"

# Burn ASS subtitles
Expand Down
15 changes: 15 additions & 0 deletions backend/services/clip_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,9 @@ def _render_with_remotion(
time_offset: float = 0.0,
logo_path: Optional[str] = None,
keep_caption_overlay: bool = False,
caption_position: str = "auto",
caption_font_scale: int = 100,
logo_position: str = "top-left",
) -> tuple[bool, Optional[str]]:
"""
Render captions using Remotion. Returns (success, optional_prores_overlay_path).
Expand Down Expand Up @@ -586,6 +589,9 @@ def _render_with_remotion(
"--words", os.path.abspath(words_file),
"--style", caption_style,
"--output", os.path.abspath(output_path),
"--caption-position", caption_position,
"--caption-font-scale", str(caption_font_scale),
"--logo-position", logo_position,
]
if logo_path and os.path.exists(logo_path):
cmd.extend(["--logo", os.path.abspath(logo_path)])
Expand Down Expand Up @@ -641,6 +647,9 @@ def generate_clip(
start_second: float,
end_second: float,
caption_style: str = "hormozi",
caption_position: str = "auto",
caption_font_scale: int = 100,
logo_position: str = "top-left",
crop_strategy: str = "face",
format: str = "vertical",
crop_keyframes: list[dict] = None,
Expand Down Expand Up @@ -908,6 +917,9 @@ def generate_clip(
time_offset=caption_time_offset,
logo_path=logo_path if (style_config.get("logo_support", False) and logo_path) else None,
keep_caption_overlay=keep_caption_overlay,
caption_position=caption_position,
caption_font_scale=caption_font_scale,
logo_position=logo_position,
)

if not remotion_ok and not allow_ass_fallback:
Expand All @@ -924,6 +936,8 @@ def generate_clip(
caption_style=caption_style,
output_path=ass_path,
time_offset=caption_time_offset,
caption_position=caption_position,
caption_font_scale=caption_font_scale,
)

use_gradient = style_config.get("gradient_overlay", False)
Expand All @@ -940,6 +954,7 @@ def generate_clip(
logo_height=style_config.get("logo_height", 80),
logo_margin_x=style_config.get("logo_margin_x", 30),
logo_margin_y=style_config.get("logo_margin_y", 40),
logo_position=logo_position,
)
else:
captioned_path = cropped_path
Expand Down
Loading