Skip to content
Merged
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
160 changes: 152 additions & 8 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,65 @@ def cmd_studio(args):
sys.exit(rc)


def cmd_reel(args):
"""Create and iterate on a highlights reel — detect once, then edit moments fast."""
from services.reel import (
ReelSession, seed_session, edit_moment, build_reel, list_sessions, delete_session,
)
from services.transcript_packer import compute_cache_hash, load_cached_transcript_for_video

def _mmss(s):
return f"{int(s // 60)}:{int(s % 60):02d}"

def _show(session):
for i, m in enumerate(session.moments, 1):
flag = "" if m.enabled else " (disabled)"
print(f" [{i}] {_mmss(m.start)}-{_mmss(m.end)} ({m.duration:.0f}s, {m.why}){flag}")
if m.text:
print(" " + (m.text[:150] + "…" if len(m.text) > 150 else m.text))

action = getattr(args, "reel_action", None)
if action == "new":
video = _clean_path(args.video)
sid = compute_cache_hash(video)
out_dir = args.output or os.path.join(os.getcwd(), f"reel_{sid[:8]}")
cached = load_cached_transcript_for_video(video)
words = cached.get("words") if cached else None
print(" Detecting moments (one-time)...")
session = seed_session(
sid, video, out_dir, profile=args.profile or "auto",
format=args.format or "horizontal", top_n=args.top or 10,
min_dur=args.min_dur, max_dur=args.max_dur,
words=words, progress_callback=lambda p, m: print(f" {m}") if m else None,
)
_show(session)
print(" Building reel...")
reel = build_reel(session)
print(f" ✓ {reel}")
print(f" session: {sid}")
print(f" edit with: podcli reel edit {sid} <N> <longer|shorter|earlier|later|shift|drop|toggle> [secs]")
elif action == "list":
for s in list_sessions():
print(f" {s['session_id']} {s['profile']}/{s['format']} "
f"{s['enabled_count']}/{s['moment_count']} moments {os.path.basename(s['source'])}")
elif action == "delete":
ok = delete_session(args.session)
print(f" {'✓ deleted' if ok else '✗ no such session'} {args.session}")
elif action == "show":
_show(ReelSession.load(args.session))
elif action == "edit":
session = edit_moment(ReelSession.load(args.session), args.index, args.op, args.seconds)
reel = build_reel(session)
print(f" ✓ rebuilt {reel}")
_show(session)
elif action == "build":
reel = build_reel(ReelSession.load(args.session))
print(f" ✓ {reel}")
else:
print(" Usage: podcli reel new <video> | list | show <session> | "
"edit <session> N <op> [secs] | build <session> | delete <session>")


def cmd_process(args):
"""Full auto pipeline: transcribe → suggest → export."""
from services.clip_generator import generate_clip
Expand Down Expand Up @@ -452,6 +511,8 @@ def cmd_process(args):
config["crop_strategy"] = args.crop
if getattr(args, "format", None):
config["format"] = args.format
if getattr(args, "profile", None):
config["profile"] = args.profile
if getattr(args, "thumbnails", None) is not None:
config["generate_thumbnails"] = args.thumbnails
if args.top:
Expand All @@ -468,7 +529,9 @@ def cmd_process(args):
else:
print(f" Warning: Logo '{args.logo}' not found (checked assets and filesystem)", file=sys.stderr)
config["logo_path"] = args.logo # pass through anyway
if args.outro:
if getattr(args, "no_outro", False):
config["outro_path"] = ""
elif args.outro:
from services.asset_store import resolve as resolve_asset_outro
resolved = resolve_asset_outro(args.outro)
if resolved:
Expand All @@ -477,10 +540,14 @@ def cmd_process(args):
print(f" Warning: Outro '{args.outro}' not found (checked assets and filesystem)", file=sys.stderr)
config["outro_path"] = args.outro
elif not config.get("outro_path"):
from services.asset_store import default_outro
auto_outro = default_outro()
if auto_outro:
config["outro_path"] = auto_outro
# Highlight profiles (party/action) are raw moments, not branded shorts, so they
# skip the auto-outro; the podcast flow keeps it.
from services.profiles import get_profile as _get_profile
if _get_profile(config.get("profile")).candidate_source != "saliency":
from services.asset_store import default_outro
auto_outro = default_outro()
if auto_outro:
config["outro_path"] = auto_outro
if args.time_adjust is not None:
config["time_adjust"] = args.time_adjust
if args.no_energy:
Expand Down Expand Up @@ -554,11 +621,31 @@ def cmd_process(args):
segments = []
result = {}

# Saliency profiles (party/action) select on audio/visual signals, not dialogue,
# so transcribing a long video would be wasted work — skip it entirely.
from services.profiles import get_profile

content_profile = get_profile(config.get("profile"))
skip_transcript = content_profile.candidate_source == "saliency" and not args.transcript

from services.transcript_packer import (
load_cached_transcript_for_video,
save_cached_transcript_for_video,
)

if skip_transcript:
# Reuse an existing transcript so highlight boundaries snap to whole sentences;
# only skip transcription outright when there is none (true no-dialogue footage).
cached = load_cached_transcript_for_video(video_path)
if cached and not config.get("no_cache", False):
words = cached["words"]
segments = cached["segments"]
result = cached
skip_transcript = False
print(f" [1/4] Reusing cached transcript ({len(segments)} segments) for sentence-clean cuts")
else:
print(f" [1/4] Skipping transcription ({content_profile.name} profile uses audio/visual signals)")

if args.transcript:
print(" [1/4] Loading transcript...")
with open(args.transcript, "r", encoding="utf-8") as f:
Expand All @@ -584,7 +671,7 @@ def cmd_process(args):
words = parsed["words"]
segments = parsed["segments"]
print(f" Parsed: {len(segments)} segments, {len(words)} words")
else:
elif not skip_transcript:
# Check cache first
cached = load_cached_transcript_for_video(video_path)
if cached and not config.get("no_cache", False):
Expand Down Expand Up @@ -663,7 +750,7 @@ def _transcribe_progress(pct, msg):
else:
print(f" No speaker data (center crop fallback)")

if not segments:
if not segments and not skip_transcript:
print(" Error: No transcript segments found.", file=sys.stderr)
sys.exit(1)

Expand Down Expand Up @@ -705,11 +792,37 @@ def _transcribe_progress(pct, msg):
clips = resumed
resumed_from_session = True

# Saliency profiles (party/action) pick moments from the fused laughter/energy
# curve rather than the transcript, so they work on footage with no dialogue.
if content_profile.candidate_source == "saliency" and not clips:
from services.saliency import detect_highlights
from services.formats import get_format

spec = get_format(config.get("format", "vertical"))
print(f" [3/4] Detecting {content_profile.name} highlights (laughter + energy)...")
clips = detect_highlights(
video_path,
profile_name=content_profile.name,
top_n=top_n,
min_dur=15.0,
max_dur=min(60.0, float(spec.dur_max)),
segments=segments or None,
words=words or None,
progress_callback=lambda pct, msg: print(f" {msg}") if msg else None,
)
if clips:
print(f" ✓ {len(clips)} highlights found")
_save_suggestions_session(cache_hash, top_n, "saliency", clips, selection_sig)
else:
print(" ⚠ No highlights found, falling back to transcript selection")

# Try an AI CLI first (uses PodStack knowledge base for intelligent selection)
from services.claude_suggest import suggest_initial_with_claude, _engine_label, _find_ai_cli

ai_path, ai_engine = _find_ai_cli()
if not clips and ai_path and config.get("ai_select", True):
if clips:
pass # already selected (resumed cache or saliency profile)
elif ai_path and config.get("ai_select", True):
ai_label = _engine_label(ai_engine)
print(f" [3/4] Selecting moments with {ai_label} (PodStack)...")
clips = suggest_initial_with_claude(
Expand Down Expand Up @@ -3348,8 +3461,10 @@ def main():
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)")
proc.add_argument("--profile", choices=["podcast", "party", "action"], help="Detection profile: podcast (transcript-first, default), party/action (laughter/energy highlights)")
proc.add_argument("--logo", help="Logo image (asset name or path)")
proc.add_argument("--outro", help="Outro video (asset name or path)")
proc.add_argument("--no-outro", action="store_true", help="Do not append an outro (default for highlight profiles)")
proc.add_argument("--time-adjust", type=float, help="Timestamp offset in seconds")
proc.add_argument("--no-energy", action="store_true", help="Skip audio energy analysis")
proc.add_argument("--no-speakers", action="store_true", help="Skip speaker detection (faster, uses face detection only)")
Expand All @@ -3360,6 +3475,33 @@ def main():
proc.add_argument("--review-each", action="store_true", help="Review each rendered clip interactively")
proc.add_argument("--post-review", action="store_true", help="Open the post-render review loop after export")

# ── reel (highlights, detect once then iterate fast) ──
reel_p = sub.add_parser("reel", help="Create and iterate on a highlights reel")
reel_sub = reel_p.add_subparsers(dest="reel_action")
rn = reel_sub.add_parser("new", help="Detect moments and build a reel")
rn.add_argument("video", help="Path to the source video")
rn.add_argument("--profile", choices=["auto", "party", "action"], default="auto")
rn.add_argument("--format", choices=["vertical", "horizontal", "square"], default="horizontal",
help="Reel aspect ratio (default horizontal 1920x1080)")
rn.add_argument("-n", "--top", type=int, help="Number of moments (default 10)")
rn.add_argument("--min-dur", type=float, default=15.0, dest="min_dur",
help="Shortest moment in seconds (default 15)")
rn.add_argument("--max-dur", type=float, default=60.0, dest="max_dur",
help="Longest moment in seconds (default 60)")
rn.add_argument("-o", "--output", help="Output directory")
reel_sub.add_parser("list", help="List saved reel sessions")
rdel = reel_sub.add_parser("delete", help="Delete a reel session")
rdel.add_argument("session")
rsh = reel_sub.add_parser("show", help="List the moments in a reel session")
rsh.add_argument("session", help="Session id (printed by 'reel new')")
red = reel_sub.add_parser("edit", help="Adjust one moment and rebuild")
red.add_argument("session")
red.add_argument("index", type=int, help="1-based moment number")
red.add_argument("op", choices=["longer", "shorter", "earlier", "later", "shift", "drop", "toggle"])
red.add_argument("seconds", type=float, nargs="?", default=0.0)
rbd = reel_sub.add_parser("build", help="Rebuild the reel (re-cuts only changed moments)")
rbd.add_argument("session")

# ── studio ──
studio = sub.add_parser("studio", help="Cut a fragment + add Remotion intro/outro (follow-us) bookends")
studio.add_argument("video", nargs="?", default=None, help="Path to the source video (omit only with --save-brand)")
Expand Down Expand Up @@ -3617,6 +3759,8 @@ def main():
cmd_process(args)
elif args.command == "studio":
cmd_studio(args)
elif args.command == "reel":
cmd_reel(args)
elif args.command == "thumbnails":
cmd_thumbnails(args)
elif args.command == "thumbnail-config":
Expand Down
107 changes: 107 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,111 @@ def handle_analyze_energy(task_id: str, params: dict):
emit_result(task_id, "success", data=result)


def handle_detect_highlights(task_id: str, params: dict):
"""Detect highlight clips from a video's fused signal curve (party/action profiles).

Accepts a single `video_path`, or a list of `video_paths` to pool and rank
highlights across a whole folder of clips.
"""
from services.saliency import detect_highlights, detect_highlights_pooled

video_paths = params.get("video_paths")
video_path = params.get("video_path", "")
if not video_paths and not video_path:
emit_result(task_id, "error", error="video_path or video_paths is required")
return

common = dict(
profile_name=params.get("profile", "party"),
min_dur=float(params.get("min_dur", 8.0)),
max_dur=float(params.get("max_dur", 60.0)),
progress_callback=lambda pct, msg: emit_progress(task_id, "detecting", pct, msg),
)
if video_paths:
clips = detect_highlights_pooled(
video_paths=video_paths, top_n=int(params.get("top_n", 15)), **common
)
else:
clips = detect_highlights(
video_path=video_path, top_n=int(params.get("top_n", 8)), **common
)
emit_result(task_id, "success", data={"clips": clips, "count": len(clips)})


def handle_manage_reel(task_id: str, params: dict):
"""Create and iterate on a highlights reel — the MCP surface over the reel service.

Actions: new (detect + build), list, show, edit (adjust one moment + rebuild),
build, delete.
"""
from dataclasses import asdict
from services.reel import (
ReelSession, seed_session, edit_moment, build_reel, list_sessions, delete_session,
)

action = params.get("action", "show")

def payload(session, reel=None):
moments = []
for i, m in enumerate(session.moments, 1):
d = asdict(m)
d["clip_path"] = os.path.join(session.out_dir, "clips", f"clip_{i:02d}.mp4")
d["clip_exists"] = os.path.exists(d["clip_path"])
moments.append(d)
reel_file = reel or os.path.join(session.out_dir, "highlights_reel.mp4")
return {
"session_id": session.session_id,
"source": session.source,
"format": session.format,
"out_dir": session.out_dir,
"reel_path": reel_file if os.path.exists(reel_file) else None,
"moments": moments,
}

try:
if action == "new":
from services.transcript_packer import compute_cache_hash, load_cached_transcript_for_video
video = params["video_path"]
sid = compute_cache_hash(video)
out_dir = params.get("out_dir") or os.path.join(os.getcwd(), f"reel_{sid[:8]}")
cached = load_cached_transcript_for_video(video)
words = cached.get("words") if cached else None
session = seed_session(
sid, video, out_dir, profile=params.get("profile", "auto"),
format=params.get("format", "horizontal"),
top_n=int(params.get("top_n", 10)),
min_dur=float(params.get("min_dur", 15.0)),
max_dur=float(params.get("max_dur", 60.0)),
words=words,
progress_callback=lambda p, m: emit_progress(task_id, "detecting", p, m),
)
reel = build_reel(session, progress_callback=lambda p, m: emit_progress(task_id, "building", p, m))
emit_result(task_id, "success", data=payload(session, reel))
elif action == "list":
emit_result(task_id, "success", data={"sessions": list_sessions()})
elif action == "delete":
ok = delete_session(params["session_id"])
emit_result(task_id, "success", data={"deleted": ok, "session_id": params["session_id"]})
elif action == "show":
emit_result(task_id, "success", data=payload(ReelSession.load(params["session_id"])))
elif action == "edit":
session = edit_moment(
ReelSession.load(params["session_id"]),
int(params["index"]), params["op"], float(params.get("seconds", 0.0)),
start=params.get("start"), end=params.get("end"),
)
reel = build_reel(session, progress_callback=lambda p, m: emit_progress(task_id, "building", p, m))
emit_result(task_id, "success", data=payload(session, reel))
elif action == "build":
session = ReelSession.load(params["session_id"])
reel = build_reel(session, progress_callback=lambda p, m: emit_progress(task_id, "building", p, m))
emit_result(task_id, "success", data=payload(session, reel))
else:
emit_result(task_id, "error", error=f"unknown reel action {action!r}")
except (KeyError, IndexError, ValueError, FileNotFoundError) as e:
emit_result(task_id, "error", error=str(e))


def handle_detect_encoder(task_id: str, params: dict):
"""Detect available hardware encoders."""
from services.encoder import get_encoder_info
Expand Down Expand Up @@ -621,6 +726,8 @@ def handle_run_integration_tool(task_id: str, params: dict):
"create_clip": handle_create_clip,
"batch_clips": handle_batch_clips,
"analyze_energy": handle_analyze_energy,
"detect_highlights": handle_detect_highlights,
"manage_reel": handle_manage_reel,
"pack_transcript": handle_pack_transcript,
"detect_encoder": handle_detect_encoder,
"presets": handle_presets,
Expand Down
11 changes: 11 additions & 0 deletions backend/services/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ def _weights(**kw) -> dict[str, float]:


PROFILES = {
"auto": ContentProfile(
name="auto",
candidate_source="saliency",
channel_weights=_weights(
audio_event=0.3, energy=0.25, motion=0.2, prosody=0.15, face_reaction=0.1
),
reaction_lookback_sec=7.0,
reaction_payoff_sec=2.0,
peak_min_gap_sec=8.0,
peak_top_percentile=0.15,
),
"podcast": ContentProfile(
name="podcast",
candidate_source="llm",
Expand Down
Loading
Loading