From 24ac3d27e37ecd3166e2efa2eba585c1815e4de0 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Mon, 10 Aug 2026 10:22:15 +0400 Subject: [PATCH] Templates are Pro, not cloud-only A template belongs to the account, so signing in on a laptop should mean `--template "Bold cuts"` cuts the way the studio cuts. It said nothing of the sort: templates existed only behind the web app, and the CLI had a growing pile of flags with no way to name a set of them. `podcli templates` lists what the account has. `--template` on `process` and `studio` resolves one by name or id and fills the render config from it, before the flags, so anything typed still wins. Signed out, it says templates are Pro and points at the flags, which are the same flags a free render has always taken. Nothing about an offline render changes. That is the boundary: the primitives are core and always will be, and Pro sells the named bundle of them plus the builder that composes it. Which is also why the last two changes went in as flags rather than as a template format in the engine. The rest is the same capabilities reaching the rest of core, since they only landed on `process`: `studio` takes --logo, --name-card and --motion, and the MCP create_clip and batch_create_clips carry name_card, motion and bookend_fade through. And drawing a thumbnail is finally a separate decision from welding it onto the video: --thumbnail-placement off keeps the pictures and leaves the file alone. It defaulted to welding it on because that was the only behaviour there had ever been, and "I want the picture, not in my video" had no answer. Default unchanged. --- backend/cli.py | 146 +++++++++++++++++++++++++++- backend/clip_studio.py | 26 ++++- backend/main.py | 6 ++ backend/services/podcli_cloud.py | 28 ++++++ src/handlers/create-clip.handler.ts | 14 +++ src/models/index.ts | 28 ++++++ 6 files changed, 246 insertions(+), 2 deletions(-) diff --git a/backend/cli.py b/backend/cli.py index bee88a4..3fae081 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -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 @@ -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): @@ -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 @@ -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 @@ -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 @@ -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)") @@ -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) @@ -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") @@ -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": diff --git a/backend/clip_studio.py b/backend/clip_studio.py index 7a2c9b9..e2d694a 100644 --- a/backend/clip_studio.py +++ b/backend/clip_studio.py @@ -119,7 +119,8 @@ 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) @@ -127,6 +128,7 @@ def _render_fragment(video, start, end, words, style, crop, title, out_dir): 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), ) @@ -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()] diff --git a/backend/main.py b/backend/main.py index 6368b43..58c8505 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"), @@ -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"), diff --git a/backend/services/podcli_cloud.py b/backend/services/podcli_cloud.py index 2e33d5a..1624788 100644 --- a/backend/services/podcli_cloud.py +++ b/backend/services/podcli_cloud.py @@ -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", []) diff --git a/src/handlers/create-clip.handler.ts b/src/handlers/create-clip.handler.ts index 15f183e..b68a2c1 100644 --- a/src/handlers/create-clip.handler.ts +++ b/src/handlers/create-clip.handler.ts @@ -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: @@ -198,6 +210,8 @@ export async function handleCreateClip(input: CreateClipInput): Promise 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 }), }); diff --git a/src/models/index.ts b/src/models/index.ts index 908dfaf..43c7499 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -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; @@ -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;