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.
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/CHANGELOG.md +20 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/CLAUDE.md +1 -1
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/PKG-INFO +7 -2
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/README.md +6 -1
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/pyproject.toml +1 -1
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/client.py +14 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/aiinterviews.py +13 -1
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/showreel.py +68 -7
- podojo_cli-1.30.0/src/podojo_cli/video/showreel.py +118 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_aiinterviews.py +14 -0
- podojo_cli-1.30.0/tests/test_showreel.py +247 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/uv.lock +1 -1
- podojo_cli-1.28.0/src/podojo_cli/video/showreel.py +0 -69
- podojo_cli-1.28.0/tests/test_showreel.py +0 -57
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/.github/workflows/publish.yml +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/.gitignore +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/LICENSE +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/__init__.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/__init__.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/auth.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/interviews.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/projects.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/synth.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/transcripts.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/usertests.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/commands/videos.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/config.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/main.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/synth/__init__.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/synth/driver.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/synth/session.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/version_check.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/src/podojo_cli/video/__init__.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/conftest.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_auth.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_interviews.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_projects.py +0 -0
- {podojo_cli-1.28.0 → podojo_cli-1.30.0}/tests/test_transcripts.py +0 -0
- {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.
|
|
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/)
|
|
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/)
|
|
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
|
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|