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
146 changes: 145 additions & 1 deletion backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,11 @@ def cmd_process(args):
if merged != global_corr:
save_corrections(merged)

# A named look, fetched from the account it belongs to. Applied before the
# flags below, so anything typed on the command line still wins over it.
if getattr(args, "template", None):
_apply_template(config, args.template)

# CLI overrides
if getattr(args, "engine", None):
os.environ["PODCLI_ENGINE"] = args.engine
Expand Down Expand Up @@ -592,6 +597,8 @@ def cmd_process(args):
config["bookend_fade"] = args.bookend_fade
if getattr(args, "thumbnails", None) is not None:
config["generate_thumbnails"] = args.thumbnails
if getattr(args, "thumbnail_placement", None):
config["thumbnail_placement"] = args.thumbnail_placement
if args.top:
config["top_clips"] = args.top
if getattr(args, "review_each", False):
Expand Down Expand Up @@ -1020,8 +1027,15 @@ def _transcribe_progress(pct, msg):
)
except Exception:
pass
if config.get("thumbnail_seconds"):
_thumb_intro_duration = float(config["thumbnail_seconds"])
_thumb_intro_duration = max(0.5, min(_thumb_intro_duration, 1.0))

# Where the picture goes in the video, which is a separate question from
# whether one is drawn at all. "start" keeps what podcli has always done.
_thumb_placement = config.get("thumbnail_placement", "start")
_thumb_style = config.get("thumbnail_style") or None

# Per-clip content generation needs any provider, not specifically a binary.
from services import ai_provider
_ai_cli_path = "cloud" if ai_provider.available() else None
Expand Down Expand Up @@ -1105,8 +1119,12 @@ def _transcribe_progress(pct, msg):
start_second=result.get("start_second", clip.get("start_second")),
end_second=result.get("end_second", clip.get("end_second")),
logo_path=_thumb_logo,
config=_thumb_style,
)
if thumb_paths:
if thumb_paths and _thumb_placement == "off":
print(f" + {len(thumb_paths)} thumbnail(s) in "
f"{os.path.basename(clip_thumb_dir)}/")
elif thumb_paths:
thumb_video = os.path.join(clip_thumb_dir, "thumb_frame.mp4")
_thumb_to_video(thumb_paths[0], thumb_video, duration=_thumb_intro_duration)
from services.video_processor import concat_outro
Expand Down Expand Up @@ -3658,6 +3676,111 @@ def print_help():
print()


def cmd_templates(args):
"""List the looks this account can cut in."""
from services import podcli_cloud

accent = "\033[38;2;212;135;74m"
gray = "\033[38;5;245m"
bold = "\033[1m"
reset = "\033[0m"

if not podcli_cloud.signed_in():
print(f"\n Templates come with podcli Pro.")
print(f" {gray}Sign in with{reset} {accent}podcli login{reset}"
f"{gray}, or set the look with flags:{reset}")
print(f" {gray}--caption-style --crop --format --logo --name-card --motion{reset}\n")
return

try:
found = podcli_cloud.templates()
except podcli_cloud.CloudError as exc:
print(f" Could not load templates: {exc}", file=sys.stderr)
sys.exit(1)

if not found:
print(f"\n {gray}No templates yet. Make one in the studio.{reset}\n")
return

print()
for template in found:
look = template.get("config") or {}
cut = look.get("cut") or {}
facts = [
(look.get("captions") or {}).get("preset"),
cut.get("crop"),
cut.get("format"),
]
mark = f" {accent}default{reset}" if template.get("is_default") else ""
print(f" {bold}{template.get('name', '?')}{reset}{mark}")
print(f" {gray}{' · '.join(f for f in facts if f)}{reset}")
print(f"\n {gray}Use one:{reset} {accent}podcli process video.mp4 --template "
f"\"{found[0].get('name', 'Default')}\"{reset}\n")


def _apply_template(config: dict, name_or_id: str) -> None:
"""Fill the render config from a Pro template.

Templates live with the account rather than on a machine, so the same name
means the same look on a laptop and in the studio. Everything a template
sets is something this CLI already takes as a flag — the template is a name
for a set of them, not a second way to render.
"""
from services import podcli_cloud

if not podcli_cloud.signed_in():
print(" Templates come with podcli Pro. Sign in with `podcli login`, "
"or set the look with flags.", file=sys.stderr)
sys.exit(1)

try:
template = podcli_cloud.find_template(name_or_id)
except podcli_cloud.CloudError as exc:
print(f" Could not load templates: {exc}", file=sys.stderr)
sys.exit(1)

if not template:
try:
known = ", ".join(t.get("name", "?") for t in podcli_cloud.templates()) or "none yet"
except podcli_cloud.CloudError:
known = "unknown"
print(f" No template called '{name_or_id}'. This account has: {known}", file=sys.stderr)
sys.exit(1)

look = template.get("config") or {}
cut = look.get("cut") or {}
if cut.get("crop"):
config["crop_strategy"] = cut["crop"]
if cut.get("format"):
config["format"] = cut["format"]
if cut.get("topN"):
config["top_clips"] = cut["topN"]
if (look.get("captions") or {}).get("preset"):
config["caption_style"] = look["captions"]["preset"]

# The template says which of the show's assets take part; the asset store
# says which file each one is.
from services.asset_store import default_intro, default_logo, default_outro

if (look.get("watermark") or {}).get("enabled"):
config["logo_path"] = config.get("logo_path") or default_logo()
if (look.get("intro") or {}).get("kind") == "asset":
config["intro_path"] = config.get("intro_path") or default_intro()
if (look.get("outro") or {}).get("kind") == "asset":
config["outro_path"] = config.get("outro_path") or default_outro()

card = look.get("thumbnailCard") or {}
if card:
config["generate_thumbnails"] = bool(card.get("auto", True))
config["thumbnail_placement"] = card.get("placement", "off")
if card.get("seconds"):
config["thumbnail_seconds"] = card["seconds"]
if card.get("style"):
config["thumbnail_style"] = card["style"]

print(f" Template: {template.get('name', name_or_id)}")


def cmd_login(args):
import getpass
from services import podcli_cloud
Expand Down Expand Up @@ -4001,6 +4124,11 @@ def main():
proc.add_argument("--fast", action="store_true", help="Draft mode: tiny Whisper, heuristic selection, center crop, low quality")
proc.add_argument("--thumbnails", dest="thumbnails", action="store_true", default=None, help="Force thumbnail generation on")
proc.add_argument("--no-thumbnails", dest="thumbnails", action="store_false", help="Skip thumbnail generation")
proc.add_argument("--thumbnail-placement", dest="thumbnail_placement",
choices=["off", "start"],
help="Where the thumbnail goes in the video itself. "
"off keeps the pictures and leaves the video alone (default: start)")
proc.add_argument("--template", help="Cut in a saved look (podcli Pro). Name or id.")
proc.add_argument("--caption-style", choices=["branded", "hormozi", "karaoke", "subtle"])
proc.add_argument("--crop", choices=["center", "face", "speaker", "speaker-hardcut"])
proc.add_argument("--format", choices=["vertical", "horizontal", "square"], help="Output aspect ratio (default: vertical)")
Expand Down Expand Up @@ -4070,6 +4198,17 @@ def main():
studio.add_argument("--assemblyai-api-key", help="AssemblyAI API key for --engine assemblyai. Prefer ASSEMBLYAI_API_KEY; command-line secrets can appear in process listings.")
studio.add_argument("--caption-style", choices=["hormozi", "karaoke", "subtle", "branded"], default="hormozi")
studio.add_argument("--crop", choices=["center", "face", "speaker", "speaker-hardcut"], default="face")
studio.add_argument("--template", help="Cut in a saved look (podcli Pro). Name or id.")
studio.add_argument("--logo", help="Logo image (asset name or path)")
studio.add_argument("--name-card", dest="name_card",
help="Lower third naming the speaker, shown for the first seconds")
studio.add_argument("--name-card-sub", dest="name_card_sub",
help="Second line of the lower third")
studio.add_argument("--name-card-seconds", dest="name_card_seconds", type=float,
help="How long the lower third holds (default 3)")
studio.add_argument("--motion", dest="motion",
help='How each part arrives and leaves, as JSON: '
'{"captions":{"enter":"rise","exit":"fade","duration":5,"feel":"soft"}}')
studio.add_argument("-o", "--output", help="Final output path")
studio.add_argument("--intro-title", help="Intro headline (default: derived from first words)")
studio.add_argument("--outro-title", default=None)
Expand Down Expand Up @@ -4216,6 +4355,9 @@ def main():
st.add_argument("-n", "--variations", type=int, default=3, help="Number of variations to generate")
st.add_argument("--thumb-duration", type=float, default=1.5, help="Duration of thumbnail end card (default 1.5s)")

# ── templates ──
sub.add_parser("templates", help="List the saved looks on this account (podcli Pro)")

# ── corrections ──
corr = sub.add_parser("corrections", help="Manage transcript word corrections (Whisper fixes)")
corr_sub = corr.add_subparsers(dest="corrections_action")
Expand Down Expand Up @@ -4354,6 +4496,8 @@ def main():
cmd_studio(args)
elif args.command == "reel":
cmd_reel(args)
elif args.command == "templates":
cmd_templates(args)
elif args.command == "thumbnails":
cmd_thumbnails(args)
elif args.command == "thumbnail-config":
Expand Down
26 changes: 25 additions & 1 deletion backend/clip_studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,16 @@ def _find_paragraph(words: list, phrase: str) -> tuple[float, float]:
return start, end


def _render_fragment(video, start, end, words, style, crop, title, out_dir):
def _render_fragment(video, start, end, words, style, crop, title, out_dir,
logo=None, name_card=None, motion=None):
"""Render the fragment with face-crop + captions via the existing engine."""
from services.clip_generator import generate_clip
print(f" [fragment] rendering {start:.1f}s–{end:.1f}s ({style}, crop={crop})", flush=True)
res = generate_clip(
video_path=video, start_second=start, end_second=end,
caption_style=style, crop_strategy=crop,
transcript_words=words, title=title, output_dir=out_dir,
logo_path=logo, name_card=name_card, motion=motion,
clean_fillers=True, allow_ass_fallback=True,
progress_callback=lambda p, m: print(f" {p}% {m}", flush=True),
)
Expand Down Expand Up @@ -257,8 +259,30 @@ def main():
raise SystemExit("Provide either --start/--end or --paragraph")

# 1. Fragment
from services.asset_store import resolve_logo

name_card = None
if getattr(args, "name_card", None):
name_card = {
"title": args.name_card,
"subtitle": getattr(args, "name_card_sub", None),
"seconds": getattr(args, "name_card_seconds", None),
"accent": accent,
}

motion = None
if getattr(args, "motion", None):
try:
motion = json.loads(args.motion)
except (TypeError, ValueError):
print(" Warning: --motion is not valid JSON; using each style's own motion",
flush=True)

fragment = _render_fragment(
video, start, end, words, args.caption_style, args.crop, "fragment", out_dir,
logo=resolve_logo(getattr(args, "logo", None)),
name_card=name_card,
motion=motion,
)

platforms = [p.strip() for p in platforms_str.split(",") if p.strip()]
Expand Down
6 changes: 6 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ def handle_create_clip(task_id: str, params: dict):
logo_path=asset_store.resolve(params.get("logo_path")),
outro_path=asset_store.resolve(params.get("outro_path")),
intro_path=asset_store.resolve(params.get("intro_path")),
name_card=params.get("name_card"),
motion=params.get("motion"),
bookend_fade=params.get("bookend_fade", 0.0),
clean_fillers=params.get("clean_fillers", True),
face_map=params.get("face_map"),
keep_segments=params.get("keep_segments"),
Expand Down Expand Up @@ -231,6 +234,9 @@ def render_one(i: int, clip: dict) -> dict:
logo_path=asset_store.resolve(clip.get("logo_path") or params.get("logo_path")),
outro_path=asset_store.resolve(params.get("outro_path")),
intro_path=asset_store.resolve(clip.get("intro_path") or params.get("intro_path")),
name_card=clip.get("name_card") or params.get("name_card"),
motion=clip.get("motion") or params.get("motion"),
bookend_fade=params.get("bookend_fade", 0.0),
clean_fillers=params.get("clean_fillers", True),
face_map=params.get("face_map"),
keep_segments=clip.get("keep_segments"),
Expand Down
28 changes: 28 additions & 0 deletions backend/services/podcli_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,34 @@ def prompt_block() -> str:
return (payload or {}).get("block") or ""


def templates() -> list[dict]:
"""The looks this account cuts in.

Templates are a Pro feature and they belong to the account, not to a
machine: signed in on a laptop, `--template "Bold cuts"` means the same
thing it means in the studio. The look itself is still only the flags this
CLI already takes, so nothing about a free, offline render changes.
"""
payload = request("GET", "/v1/templates", timeout=30) or {}
return payload.get("templates", [])


def find_template(name_or_id: str) -> Optional[dict]:
"""Match on id first, then on name, case-insensitively."""
wanted = (name_or_id or "").strip()
if not wanted:
return None

found = templates()
for template in found:
if template.get("id") == wanted:
return template
for template in found:
if (template.get("name") or "").lower() == wanted.lower():
return template
return None


def list_workspaces() -> list[dict]:
return (request("GET", "/v1/workspaces", timeout=30) or {}).get("workspaces", [])

Expand Down
14 changes: 14 additions & 0 deletions src/handlers/create-clip.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ export const createClipToolDef = {
type: "string",
description: "Intro video (asset name or path) prepended before the clip. Uses the default intro asset if omitted.",
},
name_card: {
type: "object",
description:
"Lower third naming the speaker for the first seconds: { title, subtitle, seconds, accent }.",
},
motion: {
type: "object",
description:
'How each part arrives and leaves, per part: { captions: { enter, exit, duration, feel } }. ' +
'enter: none|fade|rise|pop, exit: none|fade|sink, feel: snap|soft|linear. ' +
"Omit to use each caption style's own motion.",
},
keep_caption_overlay: {
type: "boolean",
description:
Expand Down Expand Up @@ -198,6 +210,8 @@ export async function handleCreateClip(input: CreateClipInput): Promise<string>
logo_path: logoPath,
outro_path: outroPath,
intro_path: introPath,
...(input.name_card ? { name_card: input.name_card } : {}),
...(input.motion ? { motion: input.motion } : {}),
...(keepSegments && { keep_segments: keepSegments }),
});

Expand Down
28 changes: 28 additions & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,32 @@ export interface UIState {
lastUpdated?: number;
}

/** Who is speaking, shown as a lower third for the first seconds of a clip. */
export interface NameCard {
title: string;
subtitle?: string;
seconds?: number;
accent?: string;
}

/**
* How a part of a clip arrives and leaves.
*
* Omitted, each caption style uses the motion it has always had.
*/
export interface Motion {
enter?: "none" | "fade" | "rise" | "pop";
exit?: "none" | "fade" | "sink";
/** Frames, at the render's fps. */
duration?: number;
feel?: "snap" | "soft" | "linear";
}

export interface ClipMotion {
captions?: Motion;
nameCard?: Motion;
}

export interface CreateClipInput {
clip_number?: number;
video_path?: string;
Expand All @@ -141,6 +167,8 @@ export interface CreateClipInput {
logo_path?: string;
outro_path?: string;
intro_path?: string;
name_card?: NameCard;
motion?: ClipMotion;
transcript_words?: WordTimestamp[];
clean_fillers?: boolean;
allow_ass_fallback?: boolean;
Expand Down