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
35 changes: 19 additions & 16 deletions backend/services/clip_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from utils.proc import run as proc_run, ProcError
from utils.text import safe_filename
from utils.log import timed
from config.paths import paths

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
Expand Down Expand Up @@ -816,10 +817,11 @@ def generate_clip(
progress_callback(10, msg)

segment_path = os.path.join(work_dir, "segment.mp4")
if keep_segments and len(keep_segments) > 1:
cut_multi_segment(video_path, segment_path, keep_segments)
else:
cut_segment(video_path, segment_path, start_second, end_second)
with timed("render", "cut", segments=len(keep_segments) if keep_segments else 1):
if keep_segments and len(keep_segments) > 1:
cut_multi_segment(video_path, segment_path, keep_segments)
else:
cut_segment(video_path, segment_path, start_second, end_second)

# Remap transcript words for multi-segment clips.
# Needed before crop (speaker detection) and captions.
Expand Down Expand Up @@ -861,18 +863,19 @@ def generate_clip(
progress_callback(30, f"Resizing for {spec.name} format (2/{total_steps})")

cropped_path = os.path.join(work_dir, "cropped.mp4")
if spec.reframe:
crop_to_vertical(
segment_path, cropped_path,
strategy=crop_strategy,
transcript_words=crop_words,
clip_start=crop_clip_start,
face_map=face_map,
crop_keyframes=crop_keyframes,
target_dims=spec.dims,
)
else:
fit_to_frame(segment_path, cropped_path, target_dims=spec.dims)
with timed("render", "crop", strategy=crop_strategy if spec.reframe else "fit"):
if spec.reframe:
crop_to_vertical(
segment_path, cropped_path,
strategy=crop_strategy,
transcript_words=crop_words,
clip_start=crop_clip_start,
face_map=face_map,
crop_keyframes=crop_keyframes,
target_dims=spec.dims,
)
else:
fit_to_frame(segment_path, cropped_path, target_dims=spec.dims)

# Step 3: Render captions (Remotion-first; ASS fallback optional)
if transcript_words:
Expand Down
7 changes: 6 additions & 1 deletion backend/services/local_reframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ def count_scene_cuts(video_path: str, threshold: float = 0.35) -> int:
[
"ffmpeg",
"-i", str(video_path),
"-filter:v", f"select='gt(scene,{threshold})',showinfo",
# Audio and subtitles are irrelevant to scene detection, and the
# scene score is a whole-frame statistic — computing it on a
# 320px-wide copy gives the same cuts for a fraction of the
# decode. Downscale before select so the filter sees small frames.
"-an", "-sn",
"-filter:v", f"scale=320:-2,select='gt(scene,{threshold})',showinfo",
"-f", "null", "-",
],
timeout=180,
Expand Down
215 changes: 201 additions & 14 deletions backend/services/video_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
import subprocess
import json
import math
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Optional

from services.encoder import get_video_encode_flags
from utils.proc import run as proc_run, ProcError
from utils.log import log_event
from utils.log import log_event, timed
from services import media_probe
from services.media_probe import (
CPU_FLAGS,
Expand Down Expand Up @@ -980,6 +982,118 @@ def _face_sample_indices(total_frames: int, fps: float) -> list[int]:
return indices


# Memory ceiling for one batch of decoded frames awaiting detection. Sized so
# a 1080p clip batches ~40 frames and a 4K clip ~10 — enough to keep the pool
# busy without holding a whole clip of decoded frames in RAM.
_FACE_BATCH_BYTES = 256 * 1024 * 1024


# Each extra worker needs its own cv2.FaceDetectorYN, which costs ~21ms to
# construct against ~4.5ms per detection. Below this many frames per worker the
# construction never pays for itself and the pool makes short clips *slower*,
# so the pool is sized by workload rather than by core count alone.
_FACE_FRAMES_PER_WORKER = 32


def _face_detect_workers(sample_count: int = 0) -> int:
"""Thread count for YuNet inference during face sampling.

PODCLI_FACE_WORKERS overrides and is taken literally; 1 disables the pool
entirely, which is the first thing to try when bisecting a framing
regression. Otherwise the count is capped both by cores and by how many
frames there are to share out — a 2s clip stays serial.
"""
raw = os.environ.get("PODCLI_FACE_WORKERS", "").strip()
if raw:
try:
return max(1, int(raw))
except ValueError:
pass
by_cores = min(12, os.cpu_count() or 4)
by_work = sample_count // _FACE_FRAMES_PER_WORKER
return max(1, min(by_cores, by_work))


def _detect_batch(batch: list, detectors: list, width: int, height: int, executor) -> list:
"""Detect faces across a batch of (time, frame) pairs, input order preserved.

Each worker owns one detector and walks its own stride of the batch, so no
cv2.FaceDetectorYN instance is ever touched by two threads — they carry
per-instance input-size state and are not thread-safe. Striding (rather
than a shared work queue) also makes the assignment deterministic.
"""
# Imported here, like every other cv2 dependency in this module, so the
# module stays importable without OpenCV installed.
from services.face_detector import detect_faces

if executor is None or len(detectors) < 2 or len(batch) < 2:
det = detectors[0]
return [(t, detect_faces(det, frame, width, height)) for t, frame in batch]

n = len(detectors)

def _stride(k: int) -> list:
det = detectors[k]
return [
(i, (batch[i][0], detect_faces(det, batch[i][1], width, height)))
for i in range(k, len(batch), n)
]

merged: dict = {}
for part in executor.map(_stride, range(n)):
merged.update(part)
return [merged[i] for i in range(len(batch))]


def _dump_crop_path(
*,
input_path: str,
keyframes_x: list,
crop_w: int,
crop_h: int,
crop_y: int,
width: int,
height: int,
detections: list,
segment_tracks: list,
has_any_split: bool,
) -> None:
"""Write the computed camera path to PODCLI_CROP_DUMP/<clip>.json.

No-op unless the env var is set, and never raises: this is diagnostics and
must not be able to fail a render. Frame-level detections are summarised
rather than dumped whole — the camera path is what a regression would move.
"""
dump_dir = os.environ.get("PODCLI_CROP_DUMP")
if not dump_dir:
return
try:
os.makedirs(dump_dir, exist_ok=True)
stem = os.path.splitext(os.path.basename(input_path))[0]
payload = {
"source": os.path.basename(input_path),
"source_dims": [width, height],
"crop": {"w": crop_w, "h": crop_h, "y": crop_y},
"has_any_split": bool(has_any_split),
"detection_frames": len(detections),
"frames_with_faces": sum(1 for _, faces in detections if faces),
"segment_tracks": [
# (start, end, speaker, track_id, ...) — keep the framing-
# relevant fields, drop anything unhashable/verbose.
{"start": round(s[0], 3), "end": round(s[1], 3),
"speaker": s[2], "track_id": s[3]}
for s in segment_tracks
],
"keyframes_x": [[t, x] for t, x in keyframes_x],
}
out = os.path.join(dump_dir, f"{stem}.crop.json")
with open(out, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, sort_keys=True)
log_event("crop", "dumped-path", file=out, keyframes=len(keyframes_x))
except Exception as exc: # diagnostics must never break a render
log_event("crop", "dump-failed", level="warn", error=str(exc))


def _track_and_crop(
input_path: str,
output_path: str,
Expand Down Expand Up @@ -1032,21 +1146,78 @@ def _track_and_crop(
sample_indices = _face_sample_indices(total_frames, fps)
detections = [] # [(time, faces), ...]

# This loop is inference-bound, not decode-bound: on a 60s 1080p clip YuNet
# is ~83% of the time against ~17% for decode. So frames are decoded
# serially (cheap, and it keeps frame indices exact) and detected on a small
# pool. Detections are independent per frame and OpenCV releases the GIL
# inside YuNet, so results are bit-identical; measured 3281ms -> 1179ms for
# the stage, with detection itself going 2718ms -> 594ms.
#
# Batches are bounded by bytes rather than frame count so a 4K source does
# not hold an unbounded number of decoded frames in memory at once.
frame_bytes = max(1, width * height * 3)
batch_limit = max(1, int(_FACE_BATCH_BYTES / frame_bytes))

decode_ns = 0
detect_ns = 0
next_pos = 0
frame_idx = -1
while next_pos < len(sample_indices):
if not cap.grab():
break
frame_idx += 1
if frame_idx < sample_indices[next_pos]:
continue
next_pos += 1
ret, frame = cap.retrieve()
if not ret:
continue
t = frame_idx / fps
faces = detect_faces(detector, frame, width, height)
detections.append((t, faces))
batch: list = []

# Detector construction is inside the timed block on purpose: at ~21ms per
# instance it is a real cost, and timing only the loop would hide it.
with timed("crop", "face_sampling", frames=total_frames) as t_fields:
workers = _face_detect_workers(len(sample_indices))
detectors = [detector]
if workers > 1:
detectors += [
d for d in (create_detector(width, height) for _ in range(workers - 1))
if d is not None
]

executor = None
try:
if len(detectors) > 1:
executor = ThreadPoolExecutor(max_workers=len(detectors))

def _flush() -> None:
nonlocal detect_ns, batch
if not batch:
return
_t = time.perf_counter_ns()
detections.extend(
_detect_batch(batch, detectors, width, height, executor)
)
detect_ns += time.perf_counter_ns() - _t
batch = []

while next_pos < len(sample_indices):
_t0 = time.perf_counter_ns()
grabbed = cap.grab()
decode_ns += time.perf_counter_ns() - _t0
if not grabbed:
break
frame_idx += 1
if frame_idx < sample_indices[next_pos]:
continue
next_pos += 1
_t0 = time.perf_counter_ns()
ret, frame = cap.retrieve()
decode_ns += time.perf_counter_ns() - _t0
if not ret:
continue
batch.append((frame_idx / fps, frame))
if len(batch) >= batch_limit:
_flush()
_flush()
finally:
if executor is not None:
executor.shutdown(wait=True)

t_fields["samples"] = len(detections)
t_fields["workers"] = len(detectors)
t_fields["decode_ms"] = decode_ns // 1_000_000
t_fields["detect_ms"] = detect_ns // 1_000_000

cap.release()

Expand Down Expand Up @@ -1515,6 +1686,22 @@ def _nearest_face_cx(t_target: float, window: float = 1.5) -> float | None:
validated.append((kf_t, kf_x))
keyframes_x = validated

# ── Optional crop-path dump (regression harness) ─────────────
# Crop decisions are the least testable part of the pipeline: unit tests
# cover the helper math, and the e2e render uses a synthetic video with no
# faces in it. With PODCLI_CROP_DUMP set to a directory, every crop writes
# its computed camera path as JSON, so a change that was meant to be purely
# a speed optimization can be proven not to have moved the camera.
_dump_crop_path(
input_path=input_path,
keyframes_x=keyframes_x,
crop_w=crop_w, crop_h=crop_h, crop_y=crop_y,
width=width, height=height,
detections=detections,
segment_tracks=segment_tracks,
has_any_split=has_any_split,
)

# ── Build FFmpeg filter ──────────────────────────────────────
if not keyframes_x:
crop_x = max(0, (width - crop_w) // 2)
Expand Down
23 changes: 23 additions & 0 deletions backend/utils/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

import os
import sys
import time
from contextlib import contextmanager

_VERBOSE = os.environ.get("PODCLI_LOG_VERBOSE", "").lower() in ("1", "true", "yes")

Expand Down Expand Up @@ -40,3 +42,24 @@ def warn(category: str, message: str, **fields) -> None:

def debug(category: str, message: str, **fields) -> None:
log_event(category, message, level="debug", **fields)


@contextmanager
def timed(category: str, stage: str, **fields):
"""Time a block and emit `[category] timing stage=... ms=...` on exit.

Debug level, so stage timings stay silent unless PODCLI_LOG_VERBOSE is set.
Emits on failure too — a stage that blows up after 40s is exactly the one
worth seeing. Yields a dict the caller can add fields to before the line is
written, for counts that are only known once the block has run.
"""
extra: dict = {}
start = time.perf_counter()
try:
yield extra
finally:
elapsed_ms = int((time.perf_counter() - start) * 1000)
log_event(
category, "timing", level="debug",
stage=stage, ms=elapsed_ms, **{**fields, **extra},
Comment on lines +62 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Avoid reserved-key collisions in timed.

Expanding fields and extra alongside explicit category, message, level, stage, and ms arguments can raise TypeError in finally; during a failing block, that can mask the original exception. Filter or reserve these keys before calling log_event.

Suggested fix
     finally:
         elapsed_ms = int((time.perf_counter() - start) * 1000)
+        payload = {
+            key: value
+            for key, value in {**fields, **extra}.items()
+            if key not in {"category", "message", "level", "stage", "ms"}
+        }
         log_event(
             category, "timing", level="debug",
-            stage=stage, ms=elapsed_ms, **{**fields, **extra},
+            stage=stage, ms=elapsed_ms, **payload,
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
log_event(
category, "timing", level="debug",
stage=stage, ms=elapsed_ms, **{**fields, **extra},
payload = {
key: value
for key, value in {**fields, **extra}.items()
if key not in {"category", "message", "level", "stage", "ms"}
}
log_event(
category, "timing", level="debug",
stage=stage, ms=elapsed_ms, **payload,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/utils/log.py` around lines 62 - 64, Update the `timed` function’s
`log_event` call to remove reserved keys (`category`, `message`, `level`,
`stage`, and `ms`) from the merged `fields` and `extra` payload before
expansion. Ensure the `finally` logging path cannot raise a duplicate-key
`TypeError` that masks an exception from the timed block.

)
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Copy `.env.example` to `.env`, or export these in your shell. `setup.sh` copies
| `PODCLI_BACKEND` | resolved | Override the Python backend directory |
| `PODCLI_PYTHON` | resolved | Override the Python interpreter. The launcher exports the resolved path as `PYTHON_PATH` for internal use |
| `PODCLI_TRANSITION_AUTOFIX_PASSES` | auto | Transition QA/autofix passes. Runs only on reframes that can produce hard cuts. Set a number to force it, `0` disables; the renderer caps it at 2 |
| `PODCLI_FACE_WORKERS` | auto | Threads used for face detection during reframing. Auto sizes by core count (max 12) and by clip length, so short clips stay serial rather than paying to build detectors they cannot amortize. Set `1` to force serial — the first thing to try when bisecting a framing regression, since it changes speed only, never the result |
| `PODCLI_CROP_DUMP` | unset | Directory to write each clip's computed camera path to as `<clip>.crop.json`. Diagnostics only; use it to diff framing decisions before and after a change |
| `FFMPEG_PATH` / `FFPROBE_PATH` | `ffmpeg` / `ffprobe` | Override the FFmpeg binaries |

Installed builds provision their own Python, Node, FFmpeg, and whisper.cpp, so the
Expand Down
Loading