amagi-clipper 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amagisolutionsgroup, Inc. (AmagiLabs)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: amagi-clipper
3
+ Version: 0.1.0
4
+ Summary: Turn a long podcast, webinar or stream VOD into vertical 9:16 clips with burned-in captions. Local faster-whisper + ffmpeg, no API key.
5
+ Author-email: "AmagiLabs (Amagisolutionsgroup, Inc.)" <dev@amagisolutionsgroup.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/amagilabs/amagi-clipper
8
+ Project-URL: Source, https://github.com/amagilabs/amagi-clipper
9
+ Project-URL: Issues, https://github.com/amagilabs/amagi-clipper/issues
10
+ Keywords: podcast,shorts,clips,whisper,ffmpeg,captions,reels,tiktok,cli,claude-code
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Video :: Conversion
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: faster-whisper>=1.0.0
20
+ Requires-Dist: av<19,>=15.1.0
21
+ Requires-Dist: numpy>=1.24
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # amagi-clipper
27
+
28
+ Point it at a long podcast, webinar, or stream recording and get back vertical 9:16 clips with burned-in captions, ready for Shorts, Reels, and TikTok. It runs on your own computer with no API key and no subscription.
29
+
30
+ ![demo](assets/demo.gif)
31
+
32
+ amagi-clipper transcribes the recording locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper). It then scores every 20 to 75 second run of whole sentences on how well it stands alone, and uses ffmpeg to cut the top picks into 1080x1920 mp4s. Captions appear up to three words at a time, with the word being spoken in yellow.
33
+
34
+ It was built for solo podcasters and small agencies who record every week and never find the time to cut clips.
35
+
36
+ ![three clips from the demo episode](assets/clips.png)
37
+
38
+ *Frames from the three clips it cut from the demo episode (audio-only input, so it used the waveform layout).*
39
+
40
+ ## Install
41
+
42
+ ```
43
+ pipx install amagi-clipper
44
+ ```
45
+
46
+ Or from a clone of this repo: `pipx install .`
47
+
48
+ You also need [ffmpeg](https://ffmpeg.org/) on your PATH. Python 3.10 or newer. The first run downloads the Whisper model you pick. If you have an NVIDIA GPU, amagi-clipper uses it automatically. GPU transcription also needs NVIDIA's cuBLAS and cuDNN 9 libraries (see the [faster-whisper install notes](https://github.com/SYSTRAN/faster-whisper)). If they are missing, amagi-clipper says so and falls back to the CPU.
49
+
50
+ ## Usage
51
+
52
+ ```
53
+ amagi-clipper <file> [-o OUTDIR] [-n 5] [--min 20] [--max 75] [--layout auto|crop|blur|audiogram] [--model small] [--dry-run]
54
+ ```
55
+
56
+ | Option | What it does |
57
+ |---|---|
58
+ | `-o`, `--outdir` | Where run folders go (default `amagi_clipper_out`) |
59
+ | `-n`, `--count` | How many clips to cut (default 5) |
60
+ | `--min`, `--max` | Shortest and longest clip in seconds (default 20 and 75) |
61
+ | `--layout` | `crop` fills the frame from the center, `blur` puts the whole picture over a blurred copy, `audiogram` draws a waveform. Default: `crop` for video, `audiogram` for audio-only |
62
+ | `--model` | faster-whisper size: `tiny`, `base`, `small` (default), `medium`, `large-v3` |
63
+ | `--device` | `auto` (default), `cpu`, or `cuda` |
64
+ | `--language` | Spoken language code such as `en` (default: detect) |
65
+ | `--size` | Output size (default `1080x1920`) |
66
+ | `--font`, `--font-size`, `--highlight` | Caption font (default Arial), size in pixels (default 5% of frame height), and the color of the spoken word (default `#FFD400`) |
67
+ | `--no-upper` | Keep caption case as spoken instead of UPPERCASE |
68
+ | `--no-headline` | Audiogram clips: leave out the headline at the top |
69
+ | `--transcript` | Reuse a `transcript.json` from an earlier run instead of transcribing |
70
+ | `--dry-run` | Score and list the clips, render nothing |
71
+
72
+ Examples:
73
+
74
+ ```
75
+ # Five clips from a video podcast, center-cropped
76
+ amagi-clipper episode42.mp4
77
+
78
+ # A two-person or screen-share recording: keep the whole picture
79
+ amagi-clipper webinar.mp4 --layout blur -n 3
80
+
81
+ # See what it would cut before rendering anything
82
+ amagi-clipper episode42.mp4 --dry-run
83
+ ```
84
+
85
+ Transcription is the slow step, so the transcript is cached in the run folder. Run the same file again with a different `-n`, `--min`, `--max`, or `--layout` and Whisper is skipped.
86
+
87
+ ## Real output
88
+
89
+ The demo episode in `tests/fixtures/demo_episode.m4a` is 144 seconds of two synthesized voices. We wrote the script with three self-contained monologues buried among intro, outro, and back-and-forth chatter, to see whether amagi-clipper would find them. Here is the run on an RTX 3090 with the default `small` model:
90
+
91
+ ```
92
+ $ amagi-clipper tests/fixtures/demo_episode.m4a -o runs -n 3
93
+ #1 97.0 01:13-01:41 28s clip_01.mp4 "Why do most clips fail?"
94
+ #2 91.0 00:27-00:56 30s clip_02.mp4 "Here's the truth nobody tells you about growing a podcast."
95
+ #3 91.0 01:49-02:15 26s clip_03.mp4 "Stop paying $30 a month for clip software."
96
+ runs/demo_episode
97
+ ```
98
+
99
+ It picked the three monologues and skipped the intro. Transcription plus three 1080x1920 renders took 23.7 seconds of wall time.
100
+
101
+ Each run writes `<outdir>/<name>/`:
102
+
103
+ - `clip_01.mp4`, `clip_02.mp4`, ...: the vertical clips with captions burned in.
104
+ - `clip_01.srt`, ...: plain caption files, for platforms that take them.
105
+ - `clips.md`: every clip with its score, the reasons behind it, and the full text.
106
+ - `clips.json`: the same data for scripts.
107
+ - `transcript.json`: word-level timestamps, reused on the next run.
108
+
109
+ The entry for clip 1 in `clips.md` (full file in [examples/demo_episode](examples/demo_episode/clips.md)):
110
+
111
+ ```markdown
112
+ ## 1. 97.0 - [01:13 - 01:41] 28s
113
+
114
+ - File: `clip_01.mp4`
115
+ - Hook: "Why do most clips fail?"
116
+ - Score parts: hook 27, standalone 20, ending 15, length 10, boundaries 10, delivery 15
117
+ - Why: hook: opens with a question (+); hook: 'why' framing (+); hook: short first sentence, 5 words (+); standalone: clean start and finished last sentence (+); ending: lands on a full statement (+); length: 27s sits in the 26-53s sweet spot (+)
118
+ ```
119
+
120
+ ## How scoring works
121
+
122
+ amagi-clipper splits the transcript into sentences at end punctuation or at any pause of 1.2 seconds or more. Every run of whole sentences between `--min` and `--max` is a candidate. Each candidate gets up to 100 points:
123
+
124
+ | Part | Points | What earns them |
125
+ |---|---|---|
126
+ | Hook | 30 | The first sentence works as an opener: a question, a number, "here's", "why", "stop", talking to "you", short. An opener like "and", "so", or "but", or an intro line such as "welcome back", scores zero |
127
+ | Standalone | 20 | Starts clean, with no "it" or "that" pointing back at something the viewer never heard, and finishes its last sentence |
128
+ | Ending | 15 | Ends on a statement. Stopping on a question, on a short reply like "It was.", or running into the sign-off costs points |
129
+ | Length | 10 | Sits in the middle of your `--min`/`--max` range |
130
+ | Boundaries | 10 | Has a half-second pause before and after, so the cut does not clip a word |
131
+ | Delivery | 15 | Few filler words, 1.8 to 4.0 words per second, no dead air over 2.5 seconds |
132
+
133
+ The best clips are picked first and never overlap. Every point gained or lost is written to `clips.md` in plain words, so you can see why a clip ranked where it did.
134
+
135
+ ## Use it as a Claude Code skill
136
+
137
+ The repo includes a `SKILL.md`. Copy it into your skills folder:
138
+
139
+ ```
140
+ mkdir -p ~/.claude/skills/amagi-clipper
141
+ cp SKILL.md ~/.claude/skills/amagi-clipper/SKILL.md
142
+ ```
143
+
144
+ Then ask Claude to "cut five clips from episode42.mp4". It runs `amagi-clipper` and reads `clips.md` back to you.
145
+
146
+ ## Limitations
147
+
148
+ - Scoring is a heuristic, not an LLM. It finds clean, self-contained stretches with a strong first line. It cannot tell whether a joke lands. Watch the clips before you post them.
149
+ - `crop` is a fixed center crop with no face tracking. If the speaker sits off-center, use `--layout blur`.
150
+ - Whisper on a CPU is slow. On our busy test PC, `small` ran slower than real time. `--model base` or `tiny` is faster but less accurate, and an NVIDIA GPU is much faster.
151
+ - The demo episode is synthesized speech written for the test. Real conversations are messier, so expect to see more picks you would skip.
152
+
153
+ ## Related AmagiLabs projects
154
+
155
+ - [amagi-listen](https://github.com/amagilabs/amagi-listen): give Claude a podcast or meeting recording and get back a chaptered transcript.
156
+ - [amagi-watch](https://github.com/amagilabs/amagi-watch): give Claude a video URL and get back a transcript and key frames.
157
+ - More tools at [github.com/amagilabs](https://github.com/amagilabs).
158
+
159
+ If amagi-clipper saved you an afternoon of scrubbing through a timeline, a star helps other podcasters find it.
160
+
161
+ ## License
162
+
163
+ MIT. Copyright (c) 2026 Amagisolutionsgroup, Inc. (AmagiLabs). See [LICENSE](LICENSE).
164
+
165
+ Built by [AmagiLabs](https://amagilabs.ai).
@@ -0,0 +1,140 @@
1
+ # amagi-clipper
2
+
3
+ Point it at a long podcast, webinar, or stream recording and get back vertical 9:16 clips with burned-in captions, ready for Shorts, Reels, and TikTok. It runs on your own computer with no API key and no subscription.
4
+
5
+ ![demo](assets/demo.gif)
6
+
7
+ amagi-clipper transcribes the recording locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper). It then scores every 20 to 75 second run of whole sentences on how well it stands alone, and uses ffmpeg to cut the top picks into 1080x1920 mp4s. Captions appear up to three words at a time, with the word being spoken in yellow.
8
+
9
+ It was built for solo podcasters and small agencies who record every week and never find the time to cut clips.
10
+
11
+ ![three clips from the demo episode](assets/clips.png)
12
+
13
+ *Frames from the three clips it cut from the demo episode (audio-only input, so it used the waveform layout).*
14
+
15
+ ## Install
16
+
17
+ ```
18
+ pipx install amagi-clipper
19
+ ```
20
+
21
+ Or from a clone of this repo: `pipx install .`
22
+
23
+ You also need [ffmpeg](https://ffmpeg.org/) on your PATH. Python 3.10 or newer. The first run downloads the Whisper model you pick. If you have an NVIDIA GPU, amagi-clipper uses it automatically. GPU transcription also needs NVIDIA's cuBLAS and cuDNN 9 libraries (see the [faster-whisper install notes](https://github.com/SYSTRAN/faster-whisper)). If they are missing, amagi-clipper says so and falls back to the CPU.
24
+
25
+ ## Usage
26
+
27
+ ```
28
+ amagi-clipper <file> [-o OUTDIR] [-n 5] [--min 20] [--max 75] [--layout auto|crop|blur|audiogram] [--model small] [--dry-run]
29
+ ```
30
+
31
+ | Option | What it does |
32
+ |---|---|
33
+ | `-o`, `--outdir` | Where run folders go (default `amagi_clipper_out`) |
34
+ | `-n`, `--count` | How many clips to cut (default 5) |
35
+ | `--min`, `--max` | Shortest and longest clip in seconds (default 20 and 75) |
36
+ | `--layout` | `crop` fills the frame from the center, `blur` puts the whole picture over a blurred copy, `audiogram` draws a waveform. Default: `crop` for video, `audiogram` for audio-only |
37
+ | `--model` | faster-whisper size: `tiny`, `base`, `small` (default), `medium`, `large-v3` |
38
+ | `--device` | `auto` (default), `cpu`, or `cuda` |
39
+ | `--language` | Spoken language code such as `en` (default: detect) |
40
+ | `--size` | Output size (default `1080x1920`) |
41
+ | `--font`, `--font-size`, `--highlight` | Caption font (default Arial), size in pixels (default 5% of frame height), and the color of the spoken word (default `#FFD400`) |
42
+ | `--no-upper` | Keep caption case as spoken instead of UPPERCASE |
43
+ | `--no-headline` | Audiogram clips: leave out the headline at the top |
44
+ | `--transcript` | Reuse a `transcript.json` from an earlier run instead of transcribing |
45
+ | `--dry-run` | Score and list the clips, render nothing |
46
+
47
+ Examples:
48
+
49
+ ```
50
+ # Five clips from a video podcast, center-cropped
51
+ amagi-clipper episode42.mp4
52
+
53
+ # A two-person or screen-share recording: keep the whole picture
54
+ amagi-clipper webinar.mp4 --layout blur -n 3
55
+
56
+ # See what it would cut before rendering anything
57
+ amagi-clipper episode42.mp4 --dry-run
58
+ ```
59
+
60
+ Transcription is the slow step, so the transcript is cached in the run folder. Run the same file again with a different `-n`, `--min`, `--max`, or `--layout` and Whisper is skipped.
61
+
62
+ ## Real output
63
+
64
+ The demo episode in `tests/fixtures/demo_episode.m4a` is 144 seconds of two synthesized voices. We wrote the script with three self-contained monologues buried among intro, outro, and back-and-forth chatter, to see whether amagi-clipper would find them. Here is the run on an RTX 3090 with the default `small` model:
65
+
66
+ ```
67
+ $ amagi-clipper tests/fixtures/demo_episode.m4a -o runs -n 3
68
+ #1 97.0 01:13-01:41 28s clip_01.mp4 "Why do most clips fail?"
69
+ #2 91.0 00:27-00:56 30s clip_02.mp4 "Here's the truth nobody tells you about growing a podcast."
70
+ #3 91.0 01:49-02:15 26s clip_03.mp4 "Stop paying $30 a month for clip software."
71
+ runs/demo_episode
72
+ ```
73
+
74
+ It picked the three monologues and skipped the intro. Transcription plus three 1080x1920 renders took 23.7 seconds of wall time.
75
+
76
+ Each run writes `<outdir>/<name>/`:
77
+
78
+ - `clip_01.mp4`, `clip_02.mp4`, ...: the vertical clips with captions burned in.
79
+ - `clip_01.srt`, ...: plain caption files, for platforms that take them.
80
+ - `clips.md`: every clip with its score, the reasons behind it, and the full text.
81
+ - `clips.json`: the same data for scripts.
82
+ - `transcript.json`: word-level timestamps, reused on the next run.
83
+
84
+ The entry for clip 1 in `clips.md` (full file in [examples/demo_episode](examples/demo_episode/clips.md)):
85
+
86
+ ```markdown
87
+ ## 1. 97.0 - [01:13 - 01:41] 28s
88
+
89
+ - File: `clip_01.mp4`
90
+ - Hook: "Why do most clips fail?"
91
+ - Score parts: hook 27, standalone 20, ending 15, length 10, boundaries 10, delivery 15
92
+ - Why: hook: opens with a question (+); hook: 'why' framing (+); hook: short first sentence, 5 words (+); standalone: clean start and finished last sentence (+); ending: lands on a full statement (+); length: 27s sits in the 26-53s sweet spot (+)
93
+ ```
94
+
95
+ ## How scoring works
96
+
97
+ amagi-clipper splits the transcript into sentences at end punctuation or at any pause of 1.2 seconds or more. Every run of whole sentences between `--min` and `--max` is a candidate. Each candidate gets up to 100 points:
98
+
99
+ | Part | Points | What earns them |
100
+ |---|---|---|
101
+ | Hook | 30 | The first sentence works as an opener: a question, a number, "here's", "why", "stop", talking to "you", short. An opener like "and", "so", or "but", or an intro line such as "welcome back", scores zero |
102
+ | Standalone | 20 | Starts clean, with no "it" or "that" pointing back at something the viewer never heard, and finishes its last sentence |
103
+ | Ending | 15 | Ends on a statement. Stopping on a question, on a short reply like "It was.", or running into the sign-off costs points |
104
+ | Length | 10 | Sits in the middle of your `--min`/`--max` range |
105
+ | Boundaries | 10 | Has a half-second pause before and after, so the cut does not clip a word |
106
+ | Delivery | 15 | Few filler words, 1.8 to 4.0 words per second, no dead air over 2.5 seconds |
107
+
108
+ The best clips are picked first and never overlap. Every point gained or lost is written to `clips.md` in plain words, so you can see why a clip ranked where it did.
109
+
110
+ ## Use it as a Claude Code skill
111
+
112
+ The repo includes a `SKILL.md`. Copy it into your skills folder:
113
+
114
+ ```
115
+ mkdir -p ~/.claude/skills/amagi-clipper
116
+ cp SKILL.md ~/.claude/skills/amagi-clipper/SKILL.md
117
+ ```
118
+
119
+ Then ask Claude to "cut five clips from episode42.mp4". It runs `amagi-clipper` and reads `clips.md` back to you.
120
+
121
+ ## Limitations
122
+
123
+ - Scoring is a heuristic, not an LLM. It finds clean, self-contained stretches with a strong first line. It cannot tell whether a joke lands. Watch the clips before you post them.
124
+ - `crop` is a fixed center crop with no face tracking. If the speaker sits off-center, use `--layout blur`.
125
+ - Whisper on a CPU is slow. On our busy test PC, `small` ran slower than real time. `--model base` or `tiny` is faster but less accurate, and an NVIDIA GPU is much faster.
126
+ - The demo episode is synthesized speech written for the test. Real conversations are messier, so expect to see more picks you would skip.
127
+
128
+ ## Related AmagiLabs projects
129
+
130
+ - [amagi-listen](https://github.com/amagilabs/amagi-listen): give Claude a podcast or meeting recording and get back a chaptered transcript.
131
+ - [amagi-watch](https://github.com/amagilabs/amagi-watch): give Claude a video URL and get back a transcript and key frames.
132
+ - More tools at [github.com/amagilabs](https://github.com/amagilabs).
133
+
134
+ If amagi-clipper saved you an afternoon of scrubbing through a timeline, a star helps other podcasters find it.
135
+
136
+ ## License
137
+
138
+ MIT. Copyright (c) 2026 Amagisolutionsgroup, Inc. (AmagiLabs). See [LICENSE](LICENSE).
139
+
140
+ Built by [AmagiLabs](https://amagilabs.ai).
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "amagi-clipper"
7
+ version = "0.1.0"
8
+ description = "Turn a long podcast, webinar or stream VOD into vertical 9:16 clips with burned-in captions. Local faster-whisper + ffmpeg, no API key."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "AmagiLabs (Amagisolutionsgroup, Inc.)", email = "dev@amagisolutionsgroup.com" }]
13
+ keywords = ["podcast", "shorts", "clips", "whisper", "ffmpeg", "captions", "reels", "tiktok", "cli", "claude-code"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Multimedia :: Video :: Conversion",
20
+ ]
21
+ dependencies = [
22
+ "faster-whisper>=1.0.0",
23
+ "av>=15.1.0,<19",
24
+ "numpy>=1.24",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/amagilabs/amagi-clipper"
29
+ Source = "https://github.com/amagilabs/amagi-clipper"
30
+ Issues = "https://github.com/amagilabs/amagi-clipper/issues"
31
+
32
+ [project.scripts]
33
+ amagi-clipper = "amagi_clipper.cli:main"
34
+
35
+ [project.optional-dependencies]
36
+ dev = ["pytest>=7.0"]
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """amagi-clipper: cut a long recording into vertical captioned clips, locally."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,126 @@
1
+ """Caption files for a clip: an .ass file to burn in (word-by-word highlight) and a plain .srt."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ STRIP_PUNCT = re.compile(r"^[\"'(\[]+|[\"')\],;:]+$")
7
+
8
+
9
+ def chunk_words(words: list[dict], max_words: int = 3, max_chars: int = 14) -> list[list[dict]]:
10
+ """Split words into short on-screen chunks. A chunk also ends after sentence punctuation."""
11
+ chunks: list[list[dict]] = []
12
+ cur: list[dict] = []
13
+ for w in words:
14
+ text_len = len(" ".join(x["word"] for x in cur + [w]))
15
+ if cur and (len(cur) >= max_words or text_len > max_chars):
16
+ chunks.append(cur)
17
+ cur = []
18
+ cur.append(w)
19
+ if re.search(r"[.!?]$", w["word"]):
20
+ chunks.append(cur)
21
+ cur = []
22
+ if cur:
23
+ chunks.append(cur)
24
+ return chunks
25
+
26
+
27
+ def shift(words: list[dict], offset: float) -> list[dict]:
28
+ return [{**w, "start": max(0.0, w["start"] - offset), "end": max(0.0, w["end"] - offset)} for w in words]
29
+
30
+
31
+ def ass_time(t: float) -> str:
32
+ cs = int(round(max(0.0, t) * 100))
33
+ h, cs = divmod(cs, 360000)
34
+ m, cs = divmod(cs, 6000)
35
+ s, cs = divmod(cs, 100)
36
+ return f"{h:d}:{m:02d}:{s:02d}.{cs:02d}"
37
+
38
+
39
+ def srt_time(t: float) -> str:
40
+ ms = int(round(max(0.0, t) * 1000))
41
+ h, ms = divmod(ms, 3600000)
42
+ m, ms = divmod(ms, 60000)
43
+ s, ms = divmod(ms, 1000)
44
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
45
+
46
+
47
+ def ass_escape(text: str) -> str:
48
+ return text.replace("\\", "\\\\").replace("{", "(").replace("}", ")").replace("\n", " ")
49
+
50
+
51
+ def display(word: str, upper: bool) -> str:
52
+ word = STRIP_PUNCT.sub("", word) or word
53
+ return word.upper() if upper else word
54
+
55
+
56
+ def hex_to_ass(color: str) -> str:
57
+ """'#FFD400' -> '&H00D4FF' (ASS override colors are BBGGRR)."""
58
+ c = color.lstrip("#")
59
+ if not re.fullmatch(r"[0-9A-Fa-f]{6}", c):
60
+ raise ValueError(f"color must look like #RRGGBB, got {color!r}")
61
+ r, g, b = c[0:2], c[2:4], c[4:6]
62
+ return f"&H{b}{g}{r}".upper()
63
+
64
+
65
+ def build_ass(words: list[dict], width: int, height: int, font: str = "Arial", font_size: int | None = None,
66
+ highlight: str = "#FFD400", upper: bool = True, clip_end: float | None = None,
67
+ headline: str | None = None) -> str:
68
+ """ASS subtitles with times relative to the clip start. Each chunk shows whole; the spoken word is highlighted.
69
+
70
+ `headline`, if given, sits at the top of the frame for the whole clip (used for audiogram clips).
71
+ """
72
+ size = font_size or max(24, round(height * 0.05))
73
+ margin_v = round(height * 0.22)
74
+ outline = max(2, round(size * 0.08))
75
+ hl = hex_to_ass(highlight)
76
+ lines = [
77
+ "[Script Info]",
78
+ "ScriptType: v4.00+",
79
+ f"PlayResX: {width}",
80
+ f"PlayResY: {height}",
81
+ "WrapStyle: 0",
82
+ "ScaledBorderAndShadow: yes",
83
+ "",
84
+ "[V4+ Styles]",
85
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, "
86
+ "Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, "
87
+ "MarginR, MarginV, Encoding",
88
+ f"Style: Caption,{font},{size},&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,"
89
+ f"{outline},0,2,60,60,{margin_v},1",
90
+ f"Style: Headline,{font},{round(size * 0.8)},&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,"
91
+ f"{max(1, outline // 2)},0,8,90,90,{round(height * 0.12)},1",
92
+ "",
93
+ "[Events]",
94
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
95
+ ]
96
+ if headline:
97
+ end = clip_end if clip_end is not None else (words[-1]["end"] + 0.3 if words else 0.0)
98
+ lines.append(f"Dialogue: 1,{ass_time(0)},{ass_time(end)},Headline,,0,0,0,,{ass_escape(headline)}")
99
+ chunks = chunk_words(words)
100
+ for ci, chunk in enumerate(chunks):
101
+ nxt = chunks[ci + 1][0]["start"] if ci + 1 < len(chunks) else None
102
+ chunk_end = chunk[-1]["end"] + 0.3
103
+ if nxt is not None:
104
+ # Hold the chunk through short pauses so captions don't blink off between phrases.
105
+ chunk_end = nxt if nxt - chunk[-1]["end"] < 1.0 else min(chunk_end, nxt)
106
+ if clip_end is not None:
107
+ chunk_end = min(chunk_end, clip_end)
108
+ for wi, w in enumerate(chunk):
109
+ start = w["start"]
110
+ end = chunk[wi + 1]["start"] if wi + 1 < len(chunk) else chunk_end
111
+ if end <= start:
112
+ continue
113
+ parts = []
114
+ for k, x in enumerate(chunk):
115
+ t = ass_escape(display(x["word"], upper))
116
+ parts.append(f"{{\\c{hl}&}}{t}{{\\c&HFFFFFF&}}" if k == wi else t)
117
+ lines.append(f"Dialogue: 0,{ass_time(start)},{ass_time(end)},Caption,,0,0,0,,{' '.join(parts)}")
118
+ return "\n".join(lines) + "\n"
119
+
120
+
121
+ def build_srt(words: list[dict]) -> str:
122
+ out = []
123
+ for i, chunk in enumerate(chunk_words(words, max_words=7, max_chars=42), 1):
124
+ text = " ".join(w["word"] for w in chunk)
125
+ out.append(f"{i}\n{srt_time(chunk[0]['start'])} --> {srt_time(chunk[-1]['end'])}\n{text}\n")
126
+ return "\n".join(out)