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;