podojo-cli 1.28.0__tar.gz → 1.30.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/CHANGELOG.md +20 -0
  2. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/CLAUDE.md +1 -1
  3. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/PKG-INFO +7 -2
  4. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/README.md +6 -1
  5. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/pyproject.toml +1 -1
  6. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/client.py +14 -0
  7. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/aiinterviews.py +13 -1
  8. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/showreel.py +68 -7
  9. podojo_cli-1.30.0/src/podojo_cli/video/showreel.py +118 -0
  10. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_aiinterviews.py +14 -0
  11. podojo_cli-1.30.0/tests/test_showreel.py +247 -0
  12. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/uv.lock +1 -1
  13. podojo_cli-1.28.0/src/podojo_cli/video/showreel.py +0 -69
  14. podojo_cli-1.28.0/tests/test_showreel.py +0 -57
  15. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/.github/workflows/publish.yml +0 -0
  16. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/.gitignore +0 -0
  17. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/LICENSE +0 -0
  18. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/__init__.py +0 -0
  19. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/__init__.py +0 -0
  20. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/auth.py +0 -0
  21. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/interviews.py +0 -0
  22. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/projects.py +0 -0
  23. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/synth.py +0 -0
  24. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/transcripts.py +0 -0
  25. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/usertests.py +0 -0
  26. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/videos.py +0 -0
  27. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/config.py +0 -0
  28. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/main.py +0 -0
  29. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/synth/__init__.py +0 -0
  30. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/synth/driver.py +0 -0
  31. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/synth/session.py +0 -0
  32. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/version_check.py +0 -0
  33. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/video/__init__.py +0 -0
  34. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/conftest.py +0 -0
  35. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_auth.py +0 -0
  36. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_interviews.py +0 -0
  37. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_projects.py +0 -0
  38. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_transcripts.py +0 -0
  39. {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_usertests.py +0 -0
@@ -5,6 +5,26 @@ All notable changes to the Podojo CLI will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org).
7
7
 
8
+ ## [1.30.0] - 2026-10-31
9
+
10
+ ### Added
11
+ - `showreel create --subtitles` burns English subtitles into clips from
12
+ non-English sessions. English clips stay as they are, and so do clips from
13
+ sessions transcribed before subtitles were available; both are listed at the
14
+ end. Subtitled clips sit above a caption band, so captions don't cover the UI.
15
+
16
+ ### Changed
17
+ - `showreel create` checks that ffmpeg has the filters it needs and also finds
18
+ Homebrew's `ffmpeg-full`. The plain `ffmpeg` formula no longer has text
19
+ support, so install `ffmpeg-full` if title cards fail.
20
+
21
+ ## [1.29.0] - 2026-10-30
22
+
23
+ ### Added
24
+ - `follow_up_style` on AI interviews: `guide` (the default) only covers
25
+ `probe_for`, so answers are easier to compare; `participant` explores what each
26
+ person brings up, then any gaps in `probe_for`. `podojo aiinterviews example` explains when to use each.
27
+
8
28
  ## [1.28.0] - 2026-10-29
9
29
 
10
30
  ### Added
@@ -8,7 +8,7 @@ CLI tool for the Podojo user research platform. Part of the Podojo multi-repo ar
8
8
  - Typer for CLI framework
9
9
  - httpx for HTTP client
10
10
  - Rich for terminal output
11
- - ffmpeg for video editing (system dependency, install via `brew install ffmpeg`)
11
+ - ffmpeg for video editing (system dependency, install via `brew install ffmpeg-full`; the plain formula has no text filters)
12
12
 
13
13
  ## Usage
14
14
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: podojo-cli
3
- Version: 1.28.0
3
+ Version: 1.30.0
4
4
  Summary: CLI for the Podojo user research platform
5
5
  Project-URL: Homepage, https://github.com/podojo/cli-podojo
6
6
  Project-URL: Source, https://github.com/podojo/cli-podojo
@@ -53,7 +53,7 @@ pip install podojo-cli
53
53
 
54
54
  Requires Python 3.11+. Some commands need extra tooling:
55
55
 
56
- - `showreel` requires [`ffmpeg`](https://ffmpeg.org/) on your `PATH` (`brew install ffmpeg`).
56
+ - `showreel` requires [`ffmpeg`](https://ffmpeg.org/) with text support (`brew install ffmpeg-full`; the plain `ffmpeg` formula leaves it out).
57
57
  - `synth` requires the optional `synth` extra: `pip install 'podojo-cli[synth]'` then `playwright install chromium`.
58
58
 
59
59
  ## Authentication
@@ -100,12 +100,17 @@ podojo transcripts download "Checkout Redesign" <batch-id> -o transcript.txt
100
100
  podojo videos list "Checkout Redesign"
101
101
  podojo videos download <batch-id> -o clip.mp4
102
102
  podojo showreel create clips.json -o showreel.mp4
103
+ podojo showreel create clips.json -o showreel.mp4 --subtitles
103
104
  ```
104
105
 
105
106
  The showreel `clips.json` is a list of clips, each with `batch_id`,
106
107
  `participant`, `country`, `topic`, `start`, and `end` (`MM:SS` or `HH:MM:SS`).
107
108
  Each clip gets an auto-generated title card.
108
109
 
110
+ `--subtitles` burns English subtitles into clips from non-English sessions,
111
+ translated on every run. English clips stay as they are, and so do clips from
112
+ sessions transcribed before subtitles were available; both are listed at the end.
113
+
109
114
  ### Unmoderated user tests
110
115
 
111
116
  ```bash
@@ -21,7 +21,7 @@ pip install podojo-cli
21
21
 
22
22
  Requires Python 3.11+. Some commands need extra tooling:
23
23
 
24
- - `showreel` requires [`ffmpeg`](https://ffmpeg.org/) on your `PATH` (`brew install ffmpeg`).
24
+ - `showreel` requires [`ffmpeg`](https://ffmpeg.org/) with text support (`brew install ffmpeg-full`; the plain `ffmpeg` formula leaves it out).
25
25
  - `synth` requires the optional `synth` extra: `pip install 'podojo-cli[synth]'` then `playwright install chromium`.
26
26
 
27
27
  ## Authentication
@@ -68,12 +68,17 @@ podojo transcripts download "Checkout Redesign" <batch-id> -o transcript.txt
68
68
  podojo videos list "Checkout Redesign"
69
69
  podojo videos download <batch-id> -o clip.mp4
70
70
  podojo showreel create clips.json -o showreel.mp4
71
+ podojo showreel create clips.json -o showreel.mp4 --subtitles
71
72
  ```
72
73
 
73
74
  The showreel `clips.json` is a list of clips, each with `batch_id`,
74
75
  `participant`, `country`, `topic`, `start`, and `end` (`MM:SS` or `HH:MM:SS`).
75
76
  Each clip gets an auto-generated title card.
76
77
 
78
+ `--subtitles` burns English subtitles into clips from non-English sessions,
79
+ translated on every run. English clips stay as they are, and so do clips from
80
+ sessions transcribed before subtitles were available; both are listed at the end.
81
+
77
82
  ### Unmoderated user tests
78
83
 
79
84
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "podojo-cli"
3
- version = "1.28.0"
3
+ version = "1.30.0"
4
4
  description = "CLI for the Podojo user research platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -87,6 +87,20 @@ class PodojoClient:
87
87
  r.raise_for_status()
88
88
  return r.json()
89
89
 
90
+ # Non-English clips wait for the server to translate them, retried once on
91
+ # failure.
92
+ SUBTITLES_TIMEOUT = httpx.Timeout(120.0)
93
+
94
+ def get_subtitles(self, batch_id: str, start: str, end: str) -> dict:
95
+ r = httpx.post(
96
+ f"{self.base_url}/videos/{batch_id}/subtitles",
97
+ json={"start": start, "end": end},
98
+ headers=self._headers(),
99
+ timeout=self.SUBTITLES_TIMEOUT,
100
+ )
101
+ r.raise_for_status()
102
+ return r.json()
103
+
90
104
  def list_usertests(self, skip: int = 0, limit: int = 50) -> dict:
91
105
  r = httpx.get(
92
106
  f"{self.base_url}/usertests",
@@ -34,7 +34,8 @@ EXAMPLE_YAML = """\
34
34
  #
35
35
  # Optional fields: language (en or de, default en), project_name, overview,
36
36
  # decision, screening_questions, welcome_message,
37
- # rejection_message, live, collect_contact, max_responses
37
+ # rejection_message, live, collect_contact, follow_up_style,
38
+ # max_responses
38
39
  #
39
40
  # Each question:
40
41
  # text (required) the main question, asked verbatim-ish in order
@@ -103,6 +104,13 @@ welcome_message: >
103
104
  # incentive screen after the interview (default: true). Set false to skip it.
104
105
  # collect_contact: false
105
106
 
107
+ # Optional: what the interviewer's follow-ups go after (default: guide).
108
+ # guide only covers probe_for, so answers are easier to compare. Best for
109
+ # benchmarks and concept comparisons.
110
+ # participant explores what each person brings up, then any gaps in probe_for.
111
+ # Best for understanding reasons and finding what you didn't expect.
112
+ # follow_up_style: participant
113
+
106
114
  # Optional: close the interview automatically once this many participants have
107
115
  # completed a session (default: unlimited). The interview goes off live and new
108
116
  # visitors see "not available"; participants already mid-conversation still
@@ -189,6 +197,10 @@ def validate_ai_interview_data(data: dict) -> list[str]:
189
197
  ):
190
198
  errors.append("'max_responses' must be a positive integer")
191
199
 
200
+ follow_up_style = data.get("follow_up_style")
201
+ if follow_up_style is not None and follow_up_style not in ("guide", "participant"):
202
+ errors.append("'follow_up_style' must be 'guide' or 'participant'")
203
+
192
204
  questions = data.get("questions")
193
205
  if questions is not None:
194
206
  if not isinstance(questions, list):
@@ -10,17 +10,37 @@ from rich.console import Console
10
10
  from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn
11
11
 
12
12
  from ..client import PodojoClient
13
- from ..video.showreel import extract_clip, make_title_card, concatenate
13
+ from ..video.showreel import concatenate, extract_clip, has_filters, make_title_card, write_subtitles
14
+ from .usertests import _format_api_error
14
15
 
15
16
  app = typer.Typer(help="Generate video showreels")
16
17
  console = Console()
17
18
 
19
+ # Homebrew's ffmpeg-full has the text filters but isn't linked onto the PATH
20
+ FFMPEG_FULL_PATHS = (
21
+ "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg",
22
+ "/usr/local/opt/ffmpeg-full/bin/ffmpeg",
23
+ )
18
24
 
19
- def _check_ffmpeg():
20
- if shutil.which("ffmpeg") is None:
21
- console.print("[red]Error:[/red] ffmpeg not found. Install it with: brew install ffmpeg")
25
+
26
+ def _check_ffmpeg(filters: tuple[str, ...] = ("drawtext",)):
27
+ """Puts the first ffmpeg that has the filters on the PATH for this run."""
28
+ installed = [path for path in map(shutil.which, ("ffmpeg", *FFMPEG_FULL_PATHS)) if path]
29
+ if not installed:
30
+ console.print("[red]Error:[/red] ffmpeg not found. Install it with: brew install ffmpeg-full")
22
31
  raise typer.Exit(1)
23
32
 
33
+ for path in installed:
34
+ if has_filters(path, filters):
35
+ os.environ["PATH"] = f"{os.path.dirname(path)}{os.pathsep}{os.environ['PATH']}"
36
+ return
37
+
38
+ console.print(
39
+ f"[red]Error:[/red] your ffmpeg lacks the {', '.join(filters)} filters. "
40
+ "Install the full build with: brew install ffmpeg-full"
41
+ )
42
+ raise typer.Exit(1)
43
+
24
44
 
25
45
  def _download_video(url: str, dest: Path):
26
46
  with httpx.stream("GET", url, follow_redirects=True) as r:
@@ -39,10 +59,36 @@ def _download_video(url: str, dest: Path):
39
59
  progress.update(task, advance=len(chunk))
40
60
 
41
61
 
62
+ def _fetch_subtitles(client: PodojoClient, clips: list[dict]) -> tuple[list[list[dict]], list[str]]:
63
+ """The cues of each clip plus the clips left without subtitles. Runs before
64
+ any video is downloaded, so a failed translation stops early."""
65
+ console.print(f"\nFetching subtitles for {len(clips)} clip(s)…")
66
+ subtitles, skipped = [], []
67
+
68
+ for clip in clips:
69
+ label = f"{clip['participant']} — {clip['topic']}"
70
+ try:
71
+ result = client.get_subtitles(clip["batch_id"], clip["start"], clip["end"])
72
+ except httpx.HTTPStatusError as e:
73
+ if e.response.status_code != 409:
74
+ console.print(f"[red]Error:[/red] {_format_api_error(e)}")
75
+ raise typer.Exit(1)
76
+ subtitles.append([])
77
+ skipped.append(f"{label}: transcribed before subtitles were available")
78
+ continue
79
+
80
+ subtitles.append(result["cues"])
81
+ if result["language"] == "en":
82
+ skipped.append(f"{label}: already in English")
83
+
84
+ return subtitles, skipped
85
+
86
+
42
87
  @app.command("create")
43
88
  def create_showreel(
44
89
  clips_json: Path = typer.Argument(help="Path to clips JSON file"),
45
90
  output: Path = typer.Option("showreel.mp4", "-o", "--output", help="Output .mp4 path"),
91
+ subtitles: bool = typer.Option(False, "--subtitles", help="Burn English subtitles into non-English clips"),
46
92
  ):
47
93
  """Create a video showreel from a clips JSON config.
48
94
 
@@ -53,8 +99,12 @@ def create_showreel(
53
99
  topic - short description of what the clip shows
54
100
  start - clip start time (MM:SS or HH:MM:SS)
55
101
  end - clip end time (MM:SS or HH:MM:SS)
102
+
103
+ With --subtitles, clips from non-English sessions get English subtitles.
104
+ English clips stay as they are, and so do clips from sessions transcribed
105
+ before subtitles were available; both are listed at the end.
56
106
  """
57
- _check_ffmpeg()
107
+ _check_ffmpeg(("drawtext", "ass") if subtitles else ("drawtext",))
58
108
 
59
109
  with open(clips_json, encoding="utf-8") as f:
60
110
  clips = json.load(f)
@@ -67,6 +117,8 @@ def create_showreel(
67
117
  output = output.resolve()
68
118
  output.parent.mkdir(parents=True, exist_ok=True)
69
119
 
120
+ clip_cues, skipped = _fetch_subtitles(client, clips) if subtitles else ([[] for _ in clips], [])
121
+
70
122
  with tempfile.TemporaryDirectory(prefix="podojo-showreel-") as tmp:
71
123
  tmp = Path(tmp)
72
124
 
@@ -85,7 +137,7 @@ def create_showreel(
85
137
  parts: list[str] = []
86
138
  console.print(f"\nProcessing {len(clips)} clip(s)…")
87
139
 
88
- for i, clip in enumerate(clips, 1):
140
+ for i, (clip, cues) in enumerate(zip(clips, clip_cues), 1):
89
141
  participant = clip["participant"]
90
142
  country = clip["country"]
91
143
  topic = clip["topic"]
@@ -97,7 +149,11 @@ def create_showreel(
97
149
  clip_dst = str(tmp / f"_clip_{i:02d}.mp4")
98
150
 
99
151
  make_title_card(participant, country, topic, title_dst)
100
- extract_clip(src, clip["start"], clip["end"], clip_dst)
152
+ subtitles_path = None
153
+ if cues:
154
+ subtitles_path = str(tmp / f"_subtitles_{i:02d}.ass")
155
+ write_subtitles(cues, subtitles_path)
156
+ extract_clip(src, clip["start"], clip["end"], clip_dst, subtitles_path)
101
157
 
102
158
  parts.extend([title_dst, clip_dst])
103
159
 
@@ -106,3 +162,8 @@ def create_showreel(
106
162
 
107
163
  size_mb = os.path.getsize(output) / (1024 * 1024)
108
164
  console.print(f"\n[green]Done![/green] {output} ({size_mb:.1f} MB)")
165
+
166
+ if skipped:
167
+ console.print(f"\n[yellow]{len(skipped)} clip(s) without subtitles:[/yellow]")
168
+ for line in skipped:
169
+ console.print(f" {line}", markup=False)
@@ -0,0 +1,118 @@
1
+ """FFmpeg-based showreel generation from a list of clips."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ WIDTH = 1280
8
+ HEIGHT = 720
9
+ FPS = 30
10
+ # Subtitled clips leave a band below the picture, so captions never cover the UI
11
+ CAPTION_BAND = 100
12
+ AUDIO_OPTS = ["-c:a", "aac", "-ac", "1", "-ar", "16000", "-b:a", "64k"]
13
+ TITLE_DURATION = 4
14
+ ASS_HEADER = f"""[Script Info]
15
+ ScriptType: v4.00+
16
+ PlayResX: {WIDTH}
17
+ PlayResY: {HEIGHT}
18
+ WrapStyle: 0
19
+
20
+ [V4+ Styles]
21
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
22
+ Style: Default,Helvetica,30,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,60,60,12,1
23
+
24
+ [Events]
25
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
26
+ """
27
+
28
+
29
+ def _video_opts(picture_height: int, extra_filter: str = "") -> list[str]:
30
+ scale_pad = f"scale=-2:{picture_height},pad={WIDTH}:{HEIGHT}:(ow-iw)/2:0:color=0x1a1a2e"
31
+ return [
32
+ "-c:v", "libx264", "-preset", "medium", "-crf", "18",
33
+ "-vf", f"{scale_pad}{extra_filter}", "-r", str(FPS), "-pix_fmt", "yuv420p",
34
+ ]
35
+
36
+
37
+ def _run(cmd):
38
+ result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=300)
39
+ if result.returncode != 0:
40
+ raise RuntimeError(f"ffmpeg failed:\n{result.stderr[-800:]}")
41
+
42
+
43
+ def has_filters(ffmpeg: str, filters: tuple[str, ...]) -> bool:
44
+ result = subprocess.run(
45
+ [ffmpeg, "-hide_banner", "-filters"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30,
46
+ )
47
+ available = {line.split()[1] for line in result.stdout.splitlines() if len(line.split()) > 1}
48
+ return all(name in available for name in filters)
49
+
50
+
51
+ def _ass_time(ms: int) -> str:
52
+ cs = ms // 10
53
+ return f"{cs // 360000}:{cs // 6000 % 60:02d}:{cs // 100 % 60:02d}.{cs % 100:02d}"
54
+
55
+
56
+ def _ass_text(text: str) -> str:
57
+ return " ".join(text.replace("\\", "/").replace("{", "(").replace("}", ")").split())
58
+
59
+
60
+ def write_subtitles(cues: list[dict], dst: str):
61
+ with open(dst, "w", encoding="utf-8") as f:
62
+ f.write(ASS_HEADER)
63
+ for cue in cues:
64
+ start, end = _ass_time(cue["start_ms"]), _ass_time(cue["end_ms"])
65
+ f.write(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{_ass_text(cue['text'])}\n")
66
+
67
+
68
+ def extract_clip(src: str, start: str, end: str, dst: str, subtitles: str | None = None):
69
+ if subtitles:
70
+ path = Path(subtitles).as_posix().replace(":", "\\:")
71
+ video_opts = _video_opts(HEIGHT - CAPTION_BAND, f",ass='{path}'")
72
+ else:
73
+ video_opts = _video_opts(HEIGHT)
74
+ _run([
75
+ "ffmpeg", "-y", "-ss", start, "-to", end, "-i", src,
76
+ *video_opts, *AUDIO_OPTS, dst,
77
+ ])
78
+
79
+
80
+ def make_title_card(participant: str, country: str, topic: str, dst: str):
81
+ def esc(t):
82
+ return t.replace(":", "\\:").replace("'", "'\\''")
83
+
84
+ vf = (
85
+ f"drawtext=text='{esc(participant)}':fontsize=48:fontcolor=white:"
86
+ f"x=(w-text_w)/2:y=(h/2)-80:font=Helvetica,"
87
+ f"drawtext=text='{esc(country)}':fontsize=32:fontcolor=0xBBBBBB:"
88
+ f"x=(w-text_w)/2:y=(h/2)-20:font=Helvetica,"
89
+ f"drawtext=text='{esc(topic)}':fontsize=36:fontcolor=0x34D399:"
90
+ f"x=(w-text_w)/2:y=(h/2)+40:font=Helvetica"
91
+ )
92
+
93
+ _run([
94
+ "ffmpeg", "-y",
95
+ "-f", "lavfi", "-i", f"color=c=0x1a1a2e:s={WIDTH}x{HEIGHT}:d={TITLE_DURATION}:r={FPS}",
96
+ "-f", "lavfi", "-i", "anullsrc=r=16000:cl=mono",
97
+ "-t", str(TITLE_DURATION),
98
+ "-vf", vf,
99
+ "-c:v", "libx264", "-preset", "medium", "-crf", "18",
100
+ "-pix_fmt", "yuv420p",
101
+ *AUDIO_OPTS,
102
+ "-shortest",
103
+ dst,
104
+ ])
105
+
106
+
107
+ def concatenate(parts: list[str], output: str):
108
+ concat_list = os.path.join(os.path.dirname(output), "_concat.txt")
109
+ with open(concat_list, "w", encoding="utf-8") as f:
110
+ for p in parts:
111
+ f.write(f"file '{os.path.abspath(p)}'\n")
112
+
113
+ _run([
114
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_list,
115
+ "-c", "copy", output,
116
+ ])
117
+
118
+ os.remove(concat_list)
@@ -435,6 +435,20 @@ def test_validate_max_responses(runner, tmp_path):
435
435
  assert result.exit_code == 0
436
436
 
437
437
 
438
+ def test_validate_follow_up_style(runner, tmp_path):
439
+ yaml_file = tmp_path / "ok.yaml"
440
+ yaml_file.write_text(VALID_AI_INTERVIEW_YAML + "follow_up_style: guide\n")
441
+ assert runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)]).exit_code == 0
442
+
443
+
444
+ def test_validate_rejects_an_unknown_follow_up_style(runner, tmp_path):
445
+ yaml_file = tmp_path / "bad.yaml"
446
+ yaml_file.write_text(VALID_AI_INTERVIEW_YAML + "follow_up_style: freeform\n")
447
+ result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
448
+ assert result.exit_code != 0
449
+ assert "follow_up_style" in result.output
450
+
451
+
438
452
  def test_validate_max_responses_must_be_positive(runner, tmp_path):
439
453
  yaml_file = tmp_path / "interview.yaml"
440
454
  yaml_file.write_text(VALID_AI_INTERVIEW_YAML + "max_responses: 0\n")
@@ -0,0 +1,247 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from unittest.mock import patch, MagicMock
5
+
6
+ import pytest
7
+ import typer
8
+
9
+ from podojo_cli.commands.showreel import FFMPEG_FULL_PATHS, _check_ffmpeg
10
+ from podojo_cli.main import app
11
+ from podojo_cli.video.showreel import extract_clip, has_filters, write_subtitles
12
+
13
+
14
+ CLIPS = [
15
+ {
16
+ "batch_id": "batch-1",
17
+ "participant": "P01 — Alice",
18
+ "country": "UK",
19
+ "topic": "Confused by checkout",
20
+ "start": "01:00",
21
+ "end": "01:30",
22
+ }
23
+ ]
24
+
25
+
26
+ def test_create_showreel(runner, httpx_mock, tmp_path):
27
+ clips_file = tmp_path / "clips.json"
28
+ clips_file.write_text(json.dumps(CLIPS))
29
+ output_file = tmp_path / "out.mp4"
30
+
31
+ httpx_mock.add_response(
32
+ url="http://test.local/api/v1/videos/batch-1",
33
+ json={"url": "http://storage.test/video.mp4", "filename": "video.mp4"},
34
+ )
35
+ httpx_mock.add_response(
36
+ url="http://storage.test/video.mp4",
37
+ content=b"fake-video-bytes",
38
+ )
39
+
40
+ with (
41
+ patch("podojo_cli.commands.showreel.make_title_card") as mock_title,
42
+ patch("podojo_cli.commands.showreel.extract_clip") as mock_clip,
43
+ patch("podojo_cli.commands.showreel.concatenate") as mock_concat,
44
+ patch("podojo_cli.commands.showreel._check_ffmpeg"),
45
+ patch("os.path.getsize", return_value=1024 * 1024),
46
+ ):
47
+ result = runner.invoke(app, ["showreel", "create", str(clips_file), "-o", str(output_file)])
48
+
49
+ assert result.exit_code == 0, result.output
50
+ mock_title.assert_called_once_with("P01 — Alice", "UK", "Confused by checkout", mock_title.call_args[0][3])
51
+ mock_clip.assert_called_once()
52
+ mock_concat.assert_called_once()
53
+
54
+
55
+ def test_create_showreel_missing_ffmpeg(runner, tmp_path):
56
+ clips_file = tmp_path / "clips.json"
57
+ clips_file.write_text(json.dumps(CLIPS))
58
+
59
+ import shutil
60
+ with patch.object(shutil, "which", return_value=None):
61
+ result = runner.invoke(app, ["showreel", "create", str(clips_file)])
62
+
63
+ assert result.exit_code != 0
64
+
65
+
66
+ SUBTITLES_URL = "http://test.local/api/v1/videos/batch-1/subtitles"
67
+ CUES = [{"start_ms": 1200, "end_ms": 4300, "text": "I couldn't find the field"}]
68
+
69
+
70
+ def _mock_video(httpx_mock):
71
+ httpx_mock.add_response(
72
+ url="http://test.local/api/v1/videos/batch-1",
73
+ json={"url": "http://storage.test/video.mp4", "filename": "video.mp4"},
74
+ )
75
+ httpx_mock.add_response(url="http://storage.test/video.mp4", content=b"fake-video-bytes")
76
+
77
+
78
+ def _create(runner, tmp_path, *args):
79
+ clips_file = tmp_path / "clips.json"
80
+ clips_file.write_text(json.dumps(CLIPS))
81
+
82
+ with (
83
+ patch("podojo_cli.commands.showreel.make_title_card"),
84
+ patch("podojo_cli.commands.showreel.extract_clip") as mock_clip,
85
+ patch("podojo_cli.commands.showreel.write_subtitles") as mock_write,
86
+ patch("podojo_cli.commands.showreel.concatenate"),
87
+ patch("podojo_cli.commands.showreel._check_ffmpeg") as mock_check,
88
+ patch("os.path.getsize", return_value=1024 * 1024),
89
+ ):
90
+ result = runner.invoke(app, ["showreel", "create", str(clips_file), "-o", str(tmp_path / "out.mp4"), *args])
91
+
92
+ return result, mock_clip, mock_write, mock_check
93
+
94
+
95
+ def test_subtitles_are_fetched_before_the_video_and_burned_in(runner, httpx_mock, tmp_path):
96
+ httpx_mock.add_response(url=SUBTITLES_URL, json={"language": "de", "cues": CUES})
97
+ _mock_video(httpx_mock)
98
+
99
+ result, mock_clip, mock_write, mock_check = _create(runner, tmp_path, "--subtitles")
100
+
101
+ assert result.exit_code == 0, result.output
102
+ mock_check.assert_called_once_with(("drawtext", "ass"))
103
+ first = httpx_mock.get_requests()[0]
104
+ assert str(first.url) == SUBTITLES_URL
105
+ assert json.loads(first.content) == {"start": "01:00", "end": "01:30"}
106
+ subtitles_path = mock_clip.call_args.args[4]
107
+ mock_write.assert_called_once_with(CUES, subtitles_path)
108
+ assert "without subtitles" not in result.output
109
+
110
+
111
+ def test_no_subtitles_requested_makes_no_subtitles_call(runner, httpx_mock, tmp_path):
112
+ _mock_video(httpx_mock)
113
+
114
+ result, mock_clip, mock_write, mock_check = _create(runner, tmp_path)
115
+
116
+ assert result.exit_code == 0, result.output
117
+ mock_check.assert_called_once_with(("drawtext",))
118
+ assert SUBTITLES_URL not in [str(r.url) for r in httpx_mock.get_requests()]
119
+ assert mock_clip.call_args.args[4] is None
120
+ mock_write.assert_not_called()
121
+
122
+
123
+ def test_english_clip_is_left_alone_and_listed(runner, httpx_mock, tmp_path):
124
+ httpx_mock.add_response(url=SUBTITLES_URL, json={"language": "en", "cues": []})
125
+ _mock_video(httpx_mock)
126
+
127
+ result, mock_clip, mock_write, _ = _create(runner, tmp_path, "--subtitles")
128
+
129
+ assert result.exit_code == 0, result.output
130
+ assert mock_clip.call_args.args[4] is None
131
+ mock_write.assert_not_called()
132
+ assert "1 clip(s) without subtitles" in result.output
133
+ assert "already in English" in result.output
134
+
135
+
136
+ def test_silent_clip_is_not_listed_as_skipped(runner, httpx_mock, tmp_path):
137
+ httpx_mock.add_response(url=SUBTITLES_URL, json={"language": "de", "cues": []})
138
+ _mock_video(httpx_mock)
139
+
140
+ result, mock_clip, _, _ = _create(runner, tmp_path, "--subtitles")
141
+
142
+ assert result.exit_code == 0, result.output
143
+ assert mock_clip.call_args.args[4] is None
144
+ assert "without subtitles" not in result.output
145
+
146
+
147
+ def test_older_session_is_left_alone_and_listed(runner, httpx_mock, tmp_path):
148
+ httpx_mock.add_response(url=SUBTITLES_URL, status_code=409, json={"detail": "no word timings"})
149
+ _mock_video(httpx_mock)
150
+
151
+ result, mock_clip, _, _ = _create(runner, tmp_path, "--subtitles")
152
+
153
+ assert result.exit_code == 0, result.output
154
+ assert mock_clip.call_args.args[4] is None
155
+ assert "transcribed before subtitles were available" in result.output
156
+
157
+
158
+ def test_failed_translation_stops_before_any_video_is_downloaded(runner, httpx_mock, tmp_path):
159
+ httpx_mock.add_response(url=SUBTITLES_URL, status_code=502, json={"detail": "Translating the subtitles failed"})
160
+
161
+ result, mock_clip, _, _ = _create(runner, tmp_path, "--subtitles")
162
+
163
+ assert result.exit_code == 1
164
+ assert "Translating the subtitles failed" in result.output
165
+ assert len(httpx_mock.get_requests()) == 1
166
+ mock_clip.assert_not_called()
167
+
168
+
169
+ def test_write_subtitles(tmp_path):
170
+ path = tmp_path / "clip.ass"
171
+
172
+ write_subtitles([
173
+ {"start_ms": 1230, "end_ms": 62_050, "text": "Hello [PERSON_NAME], {odd}\\text\nüber"},
174
+ {"start_ms": 3_600_000, "end_ms": 3_601_000, "text": "Later"},
175
+ ], str(path))
176
+
177
+ lines = path.read_text(encoding="utf-8").splitlines()
178
+ assert "PlayResX: 1280" in lines
179
+ assert lines[-2:] == [
180
+ "Dialogue: 0,0:00:01.23,0:01:02.05,Default,,0,0,0,,Hello [PERSON_NAME], (odd)/text über",
181
+ "Dialogue: 0,1:00:00.00,1:00:01.00,Default,,0,0,0,,Later",
182
+ ]
183
+
184
+
185
+ def test_extract_clip_without_subtitles_keeps_the_full_picture():
186
+ with patch("podojo_cli.video.showreel._run") as mock_run:
187
+ extract_clip("in.mp4", "00:10", "00:20", "out.mp4")
188
+
189
+ cmd = mock_run.call_args.args[0]
190
+ assert cmd[cmd.index("-vf") + 1] == "scale=-2:720,pad=1280:720:(ow-iw)/2:0:color=0x1a1a2e"
191
+
192
+
193
+ def test_extract_clip_with_subtitles_leaves_a_band_for_them():
194
+ with patch("podojo_cli.video.showreel._run") as mock_run:
195
+ extract_clip("in.mp4", "00:10", "00:20", "out.mp4", "C:/tmp/clip.ass")
196
+
197
+ cmd = mock_run.call_args.args[0]
198
+ assert cmd[cmd.index("-vf") + 1] == (
199
+ "scale=-2:620,pad=1280:720:(ow-iw)/2:0:color=0x1a1a2e,ass='C\\:/tmp/clip.ass'"
200
+ )
201
+
202
+
203
+ def test_has_filters_reads_the_filter_list():
204
+ listing = """Filters:
205
+ T.. = Timeline support
206
+ T.C drawtext V->V Draw text on top of video frames using libfreetype library.
207
+ TSC overlay VV->V Overlay a video source on top of the input.
208
+ """
209
+ with patch("subprocess.run", return_value=MagicMock(stdout=listing)):
210
+ assert has_filters("ffmpeg", ("drawtext",))
211
+ assert not has_filters("ffmpeg", ("drawtext", "ass"))
212
+
213
+
214
+ def _which(*installed):
215
+ return lambda name: name if name in installed else None
216
+
217
+
218
+ def test_check_ffmpeg_keeps_a_capable_ffmpeg(monkeypatch):
219
+ monkeypatch.setenv("PATH", "/usr/bin")
220
+ with (
221
+ patch("shutil.which", side_effect=_which("ffmpeg")),
222
+ patch("podojo_cli.commands.showreel.has_filters", return_value=True),
223
+ ):
224
+ _check_ffmpeg()
225
+
226
+
227
+ def test_check_ffmpeg_falls_back_to_ffmpeg_full(monkeypatch):
228
+ monkeypatch.setenv("PATH", "/usr/bin")
229
+ full = FFMPEG_FULL_PATHS[0]
230
+ with (
231
+ patch("shutil.which", side_effect=_which("ffmpeg", full)),
232
+ patch("podojo_cli.commands.showreel.has_filters", side_effect=lambda path, filters: path == full),
233
+ ):
234
+ _check_ffmpeg(("drawtext", "ass"))
235
+
236
+ assert os.environ["PATH"].startswith(os.path.dirname(full))
237
+
238
+
239
+ def test_check_ffmpeg_explains_missing_filters(capsys):
240
+ with (
241
+ patch("shutil.which", side_effect=_which("ffmpeg")),
242
+ patch("podojo_cli.commands.showreel.has_filters", return_value=False),
243
+ pytest.raises(typer.Exit),
244
+ ):
245
+ _check_ffmpeg(("drawtext", "ass"))
246
+
247
+ assert "brew install ffmpeg-full" in capsys.readouterr().out
@@ -438,7 +438,7 @@ wheels = [
438
438
 
439
439
  [[package]]
440
440
  name = "podojo-cli"
441
- version = "1.28.0"
441
+ version = "1.30.0"
442
442
  source = { editable = "." }
443
443
  dependencies = [
444
444
  { name = "httpx" },
@@ -1,69 +0,0 @@
1
- """FFmpeg-based showreel generation from a list of clips."""
2
-
3
- import os
4
- import subprocess
5
-
6
- WIDTH = 1280
7
- HEIGHT = 720
8
- FPS = 30
9
- SCALE_PAD = f"scale=-2:{HEIGHT},pad={WIDTH}:{HEIGHT}:(ow-iw)/2:0:color=0x1a1a2e"
10
- VIDEO_OPTS = [
11
- "-c:v", "libx264", "-preset", "medium", "-crf", "18",
12
- "-vf", SCALE_PAD, "-r", str(FPS), "-pix_fmt", "yuv420p",
13
- ]
14
- AUDIO_OPTS = ["-c:a", "aac", "-ac", "1", "-ar", "16000", "-b:a", "64k"]
15
- TITLE_DURATION = 4
16
-
17
-
18
- def _run(cmd):
19
- result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=300)
20
- if result.returncode != 0:
21
- raise RuntimeError(f"ffmpeg failed:\n{result.stderr[-800:]}")
22
-
23
-
24
- def extract_clip(src: str, start: str, end: str, dst: str):
25
- _run([
26
- "ffmpeg", "-y", "-ss", start, "-to", end, "-i", src,
27
- *VIDEO_OPTS, *AUDIO_OPTS, dst,
28
- ])
29
-
30
-
31
- def make_title_card(participant: str, country: str, topic: str, dst: str):
32
- def esc(t):
33
- return t.replace(":", "\\:").replace("'", "'\\''")
34
-
35
- vf = (
36
- f"drawtext=text='{esc(participant)}':fontsize=48:fontcolor=white:"
37
- f"x=(w-text_w)/2:y=(h/2)-80:font=Helvetica,"
38
- f"drawtext=text='{esc(country)}':fontsize=32:fontcolor=0xBBBBBB:"
39
- f"x=(w-text_w)/2:y=(h/2)-20:font=Helvetica,"
40
- f"drawtext=text='{esc(topic)}':fontsize=36:fontcolor=0x34D399:"
41
- f"x=(w-text_w)/2:y=(h/2)+40:font=Helvetica"
42
- )
43
-
44
- _run([
45
- "ffmpeg", "-y",
46
- "-f", "lavfi", "-i", f"color=c=0x1a1a2e:s={WIDTH}x{HEIGHT}:d={TITLE_DURATION}:r={FPS}",
47
- "-f", "lavfi", "-i", "anullsrc=r=16000:cl=mono",
48
- "-t", str(TITLE_DURATION),
49
- "-vf", vf,
50
- "-c:v", "libx264", "-preset", "medium", "-crf", "18",
51
- "-pix_fmt", "yuv420p",
52
- *AUDIO_OPTS,
53
- "-shortest",
54
- dst,
55
- ])
56
-
57
-
58
- def concatenate(parts: list[str], output: str):
59
- concat_list = os.path.join(os.path.dirname(output), "_concat.txt")
60
- with open(concat_list, "w", encoding="utf-8") as f:
61
- for p in parts:
62
- f.write(f"file '{os.path.abspath(p)}'\n")
63
-
64
- _run([
65
- "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_list,
66
- "-c", "copy", output,
67
- ])
68
-
69
- os.remove(concat_list)
@@ -1,57 +0,0 @@
1
- import json
2
- from pathlib import Path
3
- from unittest.mock import patch, MagicMock
4
-
5
- from podojo_cli.main import app
6
-
7
-
8
- CLIPS = [
9
- {
10
- "batch_id": "batch-1",
11
- "participant": "P01 — Alice",
12
- "country": "UK",
13
- "topic": "Confused by checkout",
14
- "start": "01:00",
15
- "end": "01:30",
16
- }
17
- ]
18
-
19
-
20
- def test_create_showreel(runner, httpx_mock, tmp_path):
21
- clips_file = tmp_path / "clips.json"
22
- clips_file.write_text(json.dumps(CLIPS))
23
- output_file = tmp_path / "out.mp4"
24
-
25
- httpx_mock.add_response(
26
- url="http://test.local/api/v1/videos/batch-1",
27
- json={"url": "http://storage.test/video.mp4", "filename": "video.mp4"},
28
- )
29
- httpx_mock.add_response(
30
- url="http://storage.test/video.mp4",
31
- content=b"fake-video-bytes",
32
- )
33
-
34
- with (
35
- patch("podojo_cli.commands.showreel.make_title_card") as mock_title,
36
- patch("podojo_cli.commands.showreel.extract_clip") as mock_clip,
37
- patch("podojo_cli.commands.showreel.concatenate") as mock_concat,
38
- patch("podojo_cli.commands.showreel._check_ffmpeg"),
39
- patch("os.path.getsize", return_value=1024 * 1024),
40
- ):
41
- result = runner.invoke(app, ["showreel", "create", str(clips_file), "-o", str(output_file)])
42
-
43
- assert result.exit_code == 0, result.output
44
- mock_title.assert_called_once_with("P01 — Alice", "UK", "Confused by checkout", mock_title.call_args[0][3])
45
- mock_clip.assert_called_once()
46
- mock_concat.assert_called_once()
47
-
48
-
49
- def test_create_showreel_missing_ffmpeg(runner, tmp_path):
50
- clips_file = tmp_path / "clips.json"
51
- clips_file.write_text(json.dumps(CLIPS))
52
-
53
- import shutil
54
- with patch.object(shutil, "which", return_value=None):
55
- result = runner.invoke(app, ["showreel", "create", str(clips_file)])
56
-
57
- assert result.exit_code != 0
File without changes
File without changes