Skip to content

Commit 296a8fb

Browse files
committed
feat: style captions
1 parent 3a127e3 commit 296a8fb

5 files changed

Lines changed: 276 additions & 6 deletions

File tree

skills/multilingual-caption-video/SKILL.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ The bundled Python scripts declare their own dependencies using inline script me
2525

2626
- `scripts/download.py` downloads one public URL using its uv-managed `yt-dlp` Python dependency and prints the final local path.
2727
- `scripts/transcribe.py` detects the spoken language, transcribes the video, and emits timestamped JSON.
28+
- `scripts/style_captions.py` samples the future subtitle band and selects a readable, stable style for each cue.
2829
- `scripts/make_ass.py` converts translated caption JSON into styled ASS subtitles.
2930
- `scripts/deliver.py` copies a verified MP4 to the operating system's Downloads directory using a safe, collision-free filename.
3031
- `scripts/preferences.py` reads and, only with explicit user consent, saves sparse JSON preferences.
@@ -137,13 +138,16 @@ For example, on a Fontconfig system:
137138
fc-match "Noto Naskh Arabic UI"
138139
```
139140

140-
Generate the subtitle file. Unless the user requested or saved an explicit size, omit `--font-size`: the script uses 7.5% of the shorter video edge, equivalent to about 7.5% of height for landscape video and 4.2% for 9:16 video. This follows BBC authoring guidance while keeping portrait captions from becoming oversized.
141+
Before generating the subtitle file, analyze the original video behind the future subtitle band. The analyzer makes one low-resolution pass, samples the start, midpoint, and end of every cue, and assigns one stable style to the entire cue: white text with a black outline for consistently dark areas, near-black text with a white outline for consistently bright areas, or white text on a roughly 60%-opaque black box for mixed, mid-tone, or changing areas.
141142

142143
```bash
143-
uv run "$SKILL_ROOT/scripts/make_ass.py" "$WORK_DIR/captions.json" "$WORK_DIR/captions.ass" --width <WIDTH> --height <HEIGHT> --font "<FONT>"
144+
uv run "$SKILL_ROOT/scripts/style_captions.py" "<SOURCE>" "$WORK_DIR/captions.json" "$WORK_DIR/styled-captions.json"
145+
uv run "$SKILL_ROOT/scripts/make_ass.py" "$WORK_DIR/styled-captions.json" "$WORK_DIR/captions.ass" --width <WIDTH> --height <HEIGHT> --font "<FONT>"
144146
```
145147

146-
Add `--font-size <FONT_SIZE>` only for an explicit or saved size. The default ASS region uses 12 pixels of padding on each horizontal edge, so wrapping has up to `WIDTH - 24` pixels without stretching short captions. The default bottom margin is 105 pixels, placing captions 50% higher than the previous 70-pixel baseline. Override these with `--margin-horizontal` or `--margin-bottom` only when the request or visual inspection requires it.
148+
Unless the user requested or saved an explicit size, omit `--font-size` from both commands: the scripts use 7.5% of the shorter video edge, equivalent to about 7.5% of height for landscape video and 4.2% for 9:16 video. This follows BBC authoring guidance while keeping portrait captions from becoming oversized. When an explicit size is resolved, pass the same `--font-size <FONT_SIZE>` to both scripts so background sampling matches the rendered subtitle band.
149+
150+
The default ASS region uses 12 pixels of padding on each horizontal edge, so wrapping has up to `WIDTH - 24` pixels without stretching short captions. The default bottom margin is 105 pixels, placing captions 50% higher than the previous 70-pixel baseline. Override these with the same `--margin-bottom` value on both scripts, and use `--margin-horizontal` on `make_ass.py`, only when the request or visual inspection requires it.
147151

148152
### 7. Burn captions into a new MP4
149153

@@ -170,7 +174,7 @@ ffmpeg -y -ss <SPEECH_TIMESTAMP> -i "$WORK_DIR/captioned.mp4" -frames:v 1 -vf "s
170174

171175
Before sending each preview to an image or vision tool, check its byte size against that tool's upload limit. If the limit is unknown, keep the preview below 1 MiB. If it is too large, reduce the dimensions or JPEG quality, then check again; never invoke the inspection tool with a known-oversized image.
172176

173-
Confirm that captions are present, correctly shaped, legible, inside the safe area, and no more than two lines; video and audio both play; duration and dimensions match the source; and the source remains unchanged. Fix the caption data or style and render again when verification fails.
177+
Confirm that captions are present, correctly shaped, legible, inside the safe area, and no more than two lines; adaptive text or background colors remain readable without flickering within a cue; video and audio both play; duration and dimensions match the source; and the source remains unchanged. Fix the caption data or style and render again when verification fails.
174178

175179
### 9. Deliver the requested form
176180

skills/multilingual-caption-video/scripts/make_ass.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
import json
55
import math
66
from pathlib import Path
7-
from typing import TypedDict
7+
from typing import NotRequired, TypedDict
8+
9+
10+
CAPTION_STYLES = {"Default", "DarkOnLight", "Boxed"}
811

912

1013
class CaptionCue(TypedDict):
1114
start: float
1215
end: float
1316
text: str
17+
style: NotRequired[str]
1418

1519

1620
def ass_time(seconds: float) -> str:
@@ -69,6 +73,7 @@ def build_ass(
6973
dialogue = []
7074
for cue in cues:
7175
start, end, text = cue.get("start"), cue.get("end"), cue.get("text")
76+
style = cue.get("style", "Default")
7277
if (
7378
not isinstance(start, (int, float))
7479
or isinstance(start, bool)
@@ -81,8 +86,10 @@ def build_ass(
8186
raise ValueError(f"Invalid caption interval: {cue!r}")
8287
if not isinstance(text, str) or not text.strip():
8388
raise ValueError("Caption text cannot be empty")
89+
if style not in CAPTION_STYLES:
90+
raise ValueError(f"Unsupported caption style: {style!r}")
8491
dialogue.append(
85-
f"Dialogue: 0,{ass_time(start)},{ass_time(end)},Default,,0,0,0,,{ass_text(text.strip())}"
92+
f"Dialogue: 0,{ass_time(start)},{ass_time(end)},{style},,0,0,0,,{ass_text(text.strip())}"
8693
)
8794

8895
return f"""[Script Info]
@@ -95,6 +102,8 @@ def build_ass(
95102
[V4+ Styles]
96103
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
97104
Style: Default,{font},{resolved_font_size},&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,{margin_horizontal},{margin_horizontal},{margin_bottom},1
105+
Style: DarkOnLight,{font},{resolved_font_size},&H00000000,&H000000FF,&H00FFFFFF,&H80FFFFFF,-1,0,0,0,100,100,0,0,1,3,1,2,{margin_horizontal},{margin_horizontal},{margin_bottom},1
106+
Style: Boxed,{font},{resolved_font_size},&H00FFFFFF,&H000000FF,&H60000000,&H60000000,-1,0,0,0,100,100,0,0,3,8,0,2,{margin_horizontal},{margin_horizontal},{margin_bottom},1
98107
99108
[Events]
100109
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env -S uv run
2+
3+
import argparse
4+
import json
5+
import math
6+
import subprocess
7+
from pathlib import Path
8+
from typing import BinaryIO, TypedDict, cast
9+
10+
from make_ass import CaptionCue, default_font_size
11+
12+
SAMPLE_FPS = 2
13+
SAMPLE_WIDTH = 64
14+
SAMPLE_HEIGHT = 32
15+
WHITE_CONTRAST_MAX_LUMINANCE = 0.183
16+
BLACK_TEXT_MIN_LUMINANCE = 0.35
17+
18+
19+
class VideoDimensions(TypedDict):
20+
width: int
21+
height: int
22+
23+
24+
def probe_dimensions(video: Path) -> VideoDimensions:
25+
result = subprocess.run(
26+
[
27+
"ffprobe",
28+
"-v",
29+
"error",
30+
"-select_streams",
31+
"v:0",
32+
"-show_entries",
33+
"stream=width,height",
34+
"-of",
35+
"json",
36+
str(video),
37+
],
38+
check=True,
39+
capture_output=True,
40+
text=True,
41+
)
42+
payload = json.loads(result.stdout)
43+
streams = payload.get("streams", [])
44+
if not streams:
45+
raise ValueError("Video has no video stream")
46+
width, height = streams[0].get("width"), streams[0].get("height")
47+
if not isinstance(width, int) or not isinstance(height, int):
48+
raise ValueError("Video dimensions are unavailable")
49+
if width <= 0 or height <= 0:
50+
raise ValueError("Video dimensions must be positive")
51+
return {"width": width, "height": height}
52+
53+
54+
def sample_frame_indices(start: float, end: float) -> tuple[int, ...]:
55+
if not math.isfinite(start) or not math.isfinite(end) or end <= start:
56+
raise ValueError("Caption interval must be finite and increasing")
57+
inset = min(0.5, (end - start) * 0.2)
58+
times = (start + inset, (start + end) / 2, end - inset)
59+
return tuple(
60+
dict.fromkeys(max(0, round(value * SAMPLE_FPS)) for value in times)
61+
)
62+
63+
64+
def relative_luminances(frame: bytes) -> list[float]:
65+
expected = SAMPLE_WIDTH * SAMPLE_HEIGHT * 3
66+
if len(frame) != expected:
67+
raise ValueError("Unexpected RGB frame size")
68+
linear = [
69+
value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
70+
for value in (channel / 255 for channel in range(256))
71+
]
72+
return [
73+
0.2126 * linear[frame[index]]
74+
+ 0.7152 * linear[frame[index + 1]]
75+
+ 0.0722 * linear[frame[index + 2]]
76+
for index in range(0, len(frame), 3)
77+
]
78+
79+
80+
def percentile(values: list[float], fraction: float) -> float:
81+
if not values:
82+
raise ValueError("At least one luminance sample is required")
83+
ordered = sorted(values)
84+
return ordered[round((len(ordered) - 1) * fraction)]
85+
86+
87+
def choose_style(luminances: list[float]) -> str:
88+
if percentile(luminances, 0.9) <= WHITE_CONTRAST_MAX_LUMINANCE:
89+
return "Default"
90+
if percentile(luminances, 0.1) >= BLACK_TEXT_MIN_LUMINANCE:
91+
return "DarkOnLight"
92+
return "Boxed"
93+
94+
95+
def read_exact(stream: BinaryIO, size: int) -> bytes:
96+
chunks = []
97+
remaining = size
98+
while remaining:
99+
chunk = stream.read(remaining)
100+
if not chunk:
101+
break
102+
chunks.append(chunk)
103+
remaining -= len(chunk)
104+
return b"".join(chunks)
105+
106+
107+
def sampled_luminances(
108+
video: Path,
109+
required_indices: set[int],
110+
*,
111+
width: int,
112+
height: int,
113+
font_size: int,
114+
margin_bottom: int,
115+
) -> dict[int, list[float]]:
116+
band_top = max(0, height - margin_bottom - round(font_size * 2.8))
117+
band_bottom = min(height, height - margin_bottom + round(font_size * 0.35))
118+
band_height = max(1, band_bottom - band_top)
119+
video_filter = (
120+
f"crop={width}:{band_height}:0:{band_top},"
121+
f"scale={SAMPLE_WIDTH}:{SAMPLE_HEIGHT}:flags=area,"
122+
f"fps={SAMPLE_FPS}:start_time=0:round=near,format=rgb24"
123+
)
124+
process = subprocess.Popen(
125+
[
126+
"ffmpeg",
127+
"-v",
128+
"error",
129+
"-i",
130+
str(video),
131+
"-vf",
132+
video_filter,
133+
"-an",
134+
"-sn",
135+
"-f",
136+
"rawvideo",
137+
"pipe:1",
138+
],
139+
stdout=subprocess.PIPE,
140+
stderr=subprocess.PIPE,
141+
)
142+
if process.stdout is None or process.stderr is None:
143+
process.kill()
144+
raise RuntimeError("Could not open ffmpeg output pipes")
145+
146+
frame_size = SAMPLE_WIDTH * SAMPLE_HEIGHT * 3
147+
samples: dict[int, list[float]] = {}
148+
frame_index = 0
149+
stdout = cast(BinaryIO, process.stdout)
150+
while frame := read_exact(stdout, frame_size):
151+
if len(frame) != frame_size:
152+
process.kill()
153+
raise RuntimeError("ffmpeg returned an incomplete RGB frame")
154+
if frame_index in required_indices:
155+
samples[frame_index] = relative_luminances(frame)
156+
frame_index += 1
157+
158+
error = process.stderr.read().decode(errors="replace").strip()
159+
if process.wait() != 0:
160+
raise RuntimeError(error or "ffmpeg caption-background analysis failed")
161+
missing = required_indices - samples.keys()
162+
if missing:
163+
raise RuntimeError(
164+
"Video ended before all caption backgrounds were sampled"
165+
)
166+
return samples
167+
168+
169+
def style_cues(
170+
cues: list[CaptionCue],
171+
samples: dict[int, list[float]],
172+
) -> list[CaptionCue]:
173+
styled = []
174+
for cue in cues:
175+
indices = sample_frame_indices(cue["start"], cue["end"])
176+
luminances = [value for index in indices for value in samples[index]]
177+
styled_cue: CaptionCue = {
178+
"start": cue["start"],
179+
"end": cue["end"],
180+
"text": cue["text"],
181+
"style": choose_style(luminances),
182+
}
183+
styled.append(styled_cue)
184+
return styled
185+
186+
187+
def main() -> None:
188+
parser = argparse.ArgumentParser(
189+
description="Choose readable ASS caption styles from the video background."
190+
)
191+
parser.add_argument("video", type=Path)
192+
parser.add_argument("input", type=Path)
193+
parser.add_argument("output", type=Path)
194+
parser.add_argument("--font-size", type=int)
195+
parser.add_argument("--margin-bottom", type=int, default=105)
196+
args = parser.parse_args()
197+
198+
video = args.video.resolve(strict=True)
199+
parsed = json.loads(args.input.read_text(encoding="utf-8"))
200+
cues = parsed if isinstance(parsed, list) else parsed["cues"]
201+
if not cues:
202+
raise ValueError("At least one caption cue is required")
203+
dimensions = probe_dimensions(video)
204+
font_size = args.font_size or default_font_size(
205+
dimensions["width"], dimensions["height"]
206+
)
207+
indices = {
208+
index
209+
for cue in cues
210+
for index in sample_frame_indices(cue["start"], cue["end"])
211+
}
212+
samples = sampled_luminances(
213+
video,
214+
indices,
215+
width=dimensions["width"],
216+
height=dimensions["height"],
217+
font_size=font_size,
218+
margin_bottom=args.margin_bottom,
219+
)
220+
styled = style_cues(cues, samples)
221+
output = styled if isinstance(parsed, list) else {**parsed, "cues": styled}
222+
args.output.write_text(
223+
json.dumps(output, ensure_ascii=False, indent=2) + "\n",
224+
encoding="utf-8",
225+
)
226+
227+
228+
if __name__ == "__main__":
229+
main()

tests/multilingual-caption-video/evals.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"Downloads only the requested video without a playlist through the bundled uv-managed yt-dlp Python API rather than a global yt-dlp executable",
1313
"Uses timestamped multilingual transcription and Arabic translation",
1414
"Selects an installed Arabic-capable font using operating-system-appropriate discovery",
15+
"Samples the subtitle region at multiple points per cue and selects one stable high-contrast or translucent-background style for that cue",
1516
"Checks preview byte size against the vision tool limit and resizes or recompresses it before inspection when necessary",
1617
"Visually inspects the rendered captions before uploading",
1718
"Returns an HTTP(S) link to the generated MP4",
@@ -29,6 +30,7 @@
2930
"Transcribes the non-English source with the multilingual model",
3031
"Uses the requested or saved font size when present; otherwise scales the default from the video dimensions",
3132
"Uses a compatible font already installed on the current operating system without requiring fc-match",
33+
"Uses a single low-resolution video pass to assign stable per-cue styles based on the subtitle region's luminance and variation",
3234
"Keeps visual-inspection previews below the image tool's upload-size limit",
3335
"Probes and visually checks the final MP4 before delivery",
3436
"Copies file deliveries to the operating system's Downloads directory with a sanitized YYYYMMDD-original-stem-language-code-subtitles.mp4 name without overwriting an existing file",

tests/multilingual-caption-video/test_caption_video.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
load_preferences,
2929
save_preferences,
3030
)
31+
from style_captions import choose_style, sample_frame_indices, style_cues
3132
from transcribe import transcript_payload
3233

3334

@@ -63,9 +64,23 @@ def __init__(self, start: float, end: float, text: str) -> None:
6364
assert default_font_size(1080, 1920) == 81
6465
assert default_font_size(1920, 1080) == 81
6566
assert "Style: Default,Noto Naskh Arabic UI,81," in ass
67+
assert "Style: DarkOnLight,Noto Naskh Arabic UI,81," in ass
68+
assert "Style: Boxed,Noto Naskh Arabic UI,81," in ass
6669
assert ",2,12,12,105,1" in ass
6770
assert "Dialogue: 0,0:00:01.25,0:00:03.50" in ass
6871

72+
adaptive_ass = build_ass(
73+
[
74+
{
75+
"start": 1.25,
76+
"end": 3.5,
77+
"text": "Adaptive style",
78+
"style": "DarkOnLight",
79+
}
80+
]
81+
)
82+
assert "Dialogue: 0,0:00:01.25,0:00:03.50,DarkOnLight" in adaptive_ass
83+
6984
custom_ass = build_ass(
7085
[{"start": 1.25, "end": 3.5, "text": "Custom style"}],
7186
font_size=35,
@@ -75,6 +90,17 @@ def __init__(self, start: float, end: float, text: str) -> None:
7590
assert "Style: Default,Arial,35," in custom_ass
7691
assert ",2,40,40,70,1" in custom_ass
7792

93+
assert choose_style([0.05, 0.1, 0.15]) == "Default"
94+
assert choose_style([0.4, 0.7, 0.95]) == "DarkOnLight"
95+
assert choose_style([0.02, 0.5, 0.95]) == "Boxed"
96+
indices = sample_frame_indices(1.0, 3.0)
97+
assert indices == (3, 4, 5)
98+
styled = style_cues(
99+
[{"start": 1.0, "end": 3.0, "text": "Visible"}],
100+
{index: [0.5] for index in indices},
101+
)
102+
assert styled[0].get("style") == "DarkOnLight"
103+
78104
assert validate_video_url("https://8.8.8.8/video.mp4") == (
79105
"https://8.8.8.8/video.mp4"
80106
)

0 commit comments

Comments
 (0)