mediasense 0.4.1__py3-none-any.whl

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.
mediasense/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """mediasense — transcribe audio/video with word-level timestamps and edit it by text."""
2
+
3
+ __version__ = "0.4.1"
4
+
5
+ from .models import Segment, Transcript, Word # noqa: E402,F401
mediasense/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: mediasense
3
+ description: Watch and listen to videos and audio (TikTok, YouTube, Instagram, X, Vimeo links or files) to summarize, explain, transcribe or edit them with the mediasense tools instead of opening the page.
4
+ ---
5
+
6
+ # mediasense
7
+
8
+ mediasense runs on the user's computer and gives you ears and eyes for media: it downloads links,
9
+ transcribes speech (any language, timestamp per word), picks key frames, and edits audio/video by text.
10
+
11
+ ## First: use the tools, not the web
12
+
13
+ When the user pastes a TikTok / YouTube / Instagram / X / Vimeo link or points at a media file:
14
+
15
+ - **Do not** WebFetch the page or open it in a browser. Those sites block bots, and the page has no
16
+ transcript anyway. Go straight to the mediasense tools.
17
+ - The tools are named `mediasense` → `understand_media`, `transcribe`, `check_job`, … In some apps they
18
+ are listed but not loaded yet: search your tools for "mediasense" and load them.
19
+ - If no mediasense tool exists at all, mediasense isn't installed or Claude Desktop wasn't restarted.
20
+ Say so and point to https://github.com/joaoestella/mediasense#setup (quit Claude Desktop from the tray
21
+ icon and reopen it after installing). In a terminal you can also run the CLI: `mediasense --help`.
22
+
23
+ ## Golden rules
24
+
25
+ - **Long jobs.** `understand_media`, `transcribe`, `render_edit` and `watch_video` reply
26
+ `STILL WORKING — job <id>` when they need more than ~30 s (long videos on a CPU; the first run also
27
+ downloads the ~500 MB speech model). Tell the user it's processing, with the percentage, then call
28
+ `check_job(job_id)` until the result arrives. Don't start the request again.
29
+ - **Never modify the original.** Every output is a new file (`<name>.edited.mp4`, `<name>.placed.mp4`…).
30
+ - **Preview before cutting.** Run `plan_edit` (CLI: `mediasense edit … --dry-run`), show the user the cut
31
+ list in plain words ("I'll remove 14 'um's and shorten 6 pauses, 2:31 → 2:12"), then `render_edit`.
32
+ - **Transcribe once.** The transcript is saved as `<name>.transcript.json` next to the media and every
33
+ other tool finds it automatically.
34
+ - Times are seconds (`75.5`) or `m:ss.s` (`1:15.5`). Quote timestamps back to the user as `m:ss`.
35
+
36
+ ## Workflows
37
+
38
+ ### "Summarize this" / "What is this video about?" / a pasted link
39
+ 1. `understand_media(source)` with the link or file path. One call returns the post metadata, the
40
+ transcript and, for videos, contact sheets of key frames with what is said per shot.
41
+ 2. Pass `vocabulary` with names you can infer (title, hashtags, the user's message) and `language` if
42
+ known.
43
+ 3. **Cross-check speech with the screen.** Speech recognition mangles names and tickers ("YouTube 4"
44
+ for ITUB4, "Cloud Fair" for Cloudflare). Fix them using the title, description and on-screen text in
45
+ the contact sheets. Use `get_frame(path, "m:ss")` to read small text.
46
+ 4. Answer in the user's language, citing times (`at 0:42 he shows…`).
47
+
48
+ ### "When does he say X?"
49
+ `search_transcript(path, "X")` → list of start/end times. Case, accents and punctuation are ignored.
50
+
51
+ ### Cutting by text
52
+ - Remove a phrase: `cut_phrases=["off the record"]` (all occurrences; `occurrence=2` for only the 2nd).
53
+ - Remove a passage: `cut_between=[["first words of it", "last words of it"]]`.
54
+ - Remove fillers: transcribe with `verbatim=true` first (Whisper otherwise hides most "um"s), then
55
+ `fillers=true`. Context-dependent words ("like", "tipo", "né") only via `filler_words` after asking.
56
+ - Tighten pauses: `silences=0.7` (pauses over 0.7 s shrink to `silence_keep`, default 0.2 s). With music in
57
+ the background use `silence_source="transcript"`.
58
+ - Raw times: `cut_ranges=["1:02-1:05.5"]`, `keep_only="0:30-2:00"`.
59
+ - For people who edit in Premiere/Resolve/Final Cut, add `edl=true` or `fcpxml=true` so they can keep
60
+ editing on their own timeline. `srt=true` gives subtitles matching the edited version.
61
+
62
+ ### Subtitles
63
+ `export_transcript(path, ["srt"])`; `words_per_cue=2` for TikTok/Reels-style captions.
64
+
65
+ ### Putting audio into a video
66
+ 1. Transcribe the video and use `search_transcript` / `understand_media` to find the moment.
67
+ 2. `place_audio(base=video, insert=audio, at="1:23.4", mode="duck")`: `mix`, `duck` (lowers the original)
68
+ or `replace`. `insert_from`/`insert_to` use only a slice of the clip.
69
+
70
+ ### "What happens in this video?"
71
+ 1. `understand_media(source)`.
72
+ 2. Look at the contact sheets: each tile shows shot number and start time; the text lists what is said per
73
+ shot. Describe the video chronologically with timestamps; `get_frame(path, "2:10")` to inspect detail.
74
+
75
+ ## Choosing a model
76
+ `tiny`/`base`: fast drafts · `small` (default): good balance · `medium`/`large-v3`: best accuracy, slow on
77
+ CPU · `turbo`: near large-v3 quality, faster. A CUDA GPU is used automatically when its libraries exist.
78
+ `backend="whisperx"` gives tighter word boundaries (best for precise cuts) and speaker labels.
79
+
80
+ ## Troubleshooting
81
+ - A link fails to download: sites change often; the user can update with
82
+ `python -m pipx runpip mediasense install -U yt-dlp`, or set `MEDIASENSE_COOKIES_FROM_BROWSER=firefox`.
83
+ - "ffmpeg not found": the user must install FFmpeg (`winget install ffmpeg` / `brew install ffmpeg`).
84
+ - Anything else: ask the user to run `mediasense doctor` and paste the output.
mediasense/api.py ADDED
@@ -0,0 +1,278 @@
1
+ """High-level operations shared by the CLI and the MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ from pathlib import Path
7
+
8
+ from . import export
9
+ from .edit import EditPlan, PlanBuilder, remap_transcript
10
+ from .media import MediaInfo, detect_silences, probe, render_keep
11
+ from .models import Transcript, parse_time
12
+
13
+ Range = tuple[float, float]
14
+
15
+
16
+ def default_transcript_path(media: str | Path) -> Path:
17
+ p = Path(media)
18
+ return p.with_name(p.stem + ".transcript.json")
19
+
20
+
21
+ def load_transcript_for(media: str | Path | None, transcript: str | Path | None = None,
22
+ required: bool = False) -> Transcript | None:
23
+ candidates = [Path(transcript)] if transcript else []
24
+ if media is not None and not transcript:
25
+ candidates.append(default_transcript_path(media))
26
+ for c in candidates:
27
+ if c.exists():
28
+ return Transcript.load(c)
29
+ if transcript:
30
+ raise FileNotFoundError(f"transcript not found: {transcript}")
31
+ if required:
32
+ raise FileNotFoundError(
33
+ f"no transcript for {media}; run `mediasense transcribe {media}` first"
34
+ )
35
+ return None
36
+
37
+
38
+ def parse_range(text: str) -> Range:
39
+ """"1:02-1:05.5", "62..65.5" or "62,65.5" -> (62.0, 65.5)."""
40
+ for sep in ("..", ",", "-"):
41
+ if sep in text:
42
+ a, b = text.split(sep, 1)
43
+ return parse_time(a), parse_time(b)
44
+ raise ValueError(f"cannot parse range '{text}' (use START-END, e.g. 1:02-1:05)")
45
+
46
+
47
+ def plan_edit(
48
+ media: str | Path,
49
+ transcript: Transcript | None = None,
50
+ cut_phrases: Iterable[str] = (),
51
+ cut_between: Iterable[tuple[str, str]] = (),
52
+ cut_ranges: Iterable[Range] = (),
53
+ keep_only: Range | None = None,
54
+ fillers: bool = False,
55
+ filler_words: Iterable[str] = (),
56
+ silences: float | None = None,
57
+ silence_keep: float = 0.2,
58
+ silence_source: str = "audio",
59
+ noise_db: float = -35.0,
60
+ occurrence: int | None = None,
61
+ pad: float = 0.12,
62
+ ) -> tuple[EditPlan, MediaInfo, dict]:
63
+ """Build an edit plan. Returns (plan, media info, report of what matched)."""
64
+ info = probe(media)
65
+ b = PlanBuilder(info.duration, transcript, max_pad=pad)
66
+ report: dict = {"not_found": []}
67
+ for phrase in cut_phrases:
68
+ hits = b.remove_phrase(phrase, occurrence)
69
+ if not hits:
70
+ report["not_found"].append(phrase)
71
+ for start_p, end_p in cut_between:
72
+ if b.remove_between(start_p, end_p) is None:
73
+ report["not_found"].append(f"{start_p} … {end_p}")
74
+ for a, e in cut_ranges:
75
+ b.remove_range(a, e)
76
+ if keep_only:
77
+ b.keep_only(*keep_only)
78
+ if fillers or list(filler_words):
79
+ report["fillers"] = b.remove_fillers(filler_words)
80
+ if silences is not None:
81
+ if silence_source == "transcript":
82
+ gaps = b.transcript_gaps(silences)
83
+ else:
84
+ gaps = detect_silences(media, noise_db=noise_db, min_duration=silences)
85
+ report["silences"] = b.remove_silences(gaps, keep=silence_keep)
86
+ return b.build(), info, report
87
+
88
+
89
+ def apply_plan(
90
+ media: str | Path,
91
+ plan: EditPlan,
92
+ info: MediaInfo,
93
+ transcript: Transcript | None = None,
94
+ output: str | Path | None = None,
95
+ edl: str | Path | None = None,
96
+ fcpxml: str | Path | None = None,
97
+ subtitles: str | Path | None = None,
98
+ transcript_out: str | Path | None = None,
99
+ ) -> dict[str, str]:
100
+ """Render and/or export. Returns {kind: path} of everything written."""
101
+ written: dict[str, str] = {}
102
+ keep = plan.keep
103
+ if output:
104
+ render_keep(media, keep, output)
105
+ written["media"] = str(output)
106
+ if edl:
107
+ export.write(edl, export.to_edl(keep, info, title=Path(media).stem))
108
+ written["edl"] = str(edl)
109
+ if fcpxml:
110
+ export.write(fcpxml, export.to_fcpxml(keep, info, project=Path(media).stem + " (mediasense)"))
111
+ written["fcpxml"] = str(fcpxml)
112
+ if transcript and (subtitles or transcript_out):
113
+ edited = remap_transcript(transcript, plan)
114
+ if subtitles:
115
+ write_subtitles(edited, subtitles)
116
+ written["subtitles"] = str(subtitles)
117
+ if transcript_out:
118
+ edited.save(transcript_out)
119
+ written["transcript"] = str(transcript_out)
120
+ return written
121
+
122
+
123
+ def write_subtitles(transcript: Transcript, path: str | Path, words_per_cue: int | None = None,
124
+ max_chars: int = 42) -> Path:
125
+ path = Path(path)
126
+ ext = path.suffix.lower()
127
+ if ext == ".srt":
128
+ content = export.to_srt(export.subtitle_cues(transcript, max_chars=max_chars, words_per_cue=words_per_cue))
129
+ elif ext == ".vtt":
130
+ content = export.to_vtt(export.subtitle_cues(transcript, max_chars=max_chars, words_per_cue=words_per_cue))
131
+ elif ext == ".txt":
132
+ content = export.to_text(transcript)
133
+ elif ext == ".csv":
134
+ content = export.to_words_csv(transcript)
135
+ elif ext == ".json":
136
+ return transcript.save(path)
137
+ else:
138
+ raise ValueError(f"unsupported export format '{ext}' (use .srt, .vtt, .txt, .csv or .json)")
139
+ return export.write(path, content)
140
+
141
+
142
+ def outputs_for(media: str | Path, suffix: str = "edited") -> dict[str, Path]:
143
+ p = Path(media)
144
+ base = f"{p.stem}.{suffix}"
145
+
146
+ def named(ext: str) -> Path:
147
+ # build names explicitly: Path.with_suffix would swap ".edited" for the extension
148
+ return p.with_name(base + ext)
149
+
150
+ return {
151
+ "media": named(p.suffix),
152
+ "edl": named(".edl"),
153
+ "fcpxml": named(".fcpxml"),
154
+ "srt": named(".srt"),
155
+ "transcript": named(".transcript.json"),
156
+ }
157
+
158
+
159
+ def transcript_excerpt(t: Transcript, start: float | None = None, end: float | None = None,
160
+ words: bool = False, max_chars: int = 20000) -> str:
161
+ """Compact, model-friendly view with timestamps."""
162
+ from .models import fmt_time
163
+
164
+ lines: list[str] = []
165
+ for s in t.segments:
166
+ if start is not None and s.end < start:
167
+ continue
168
+ if end is not None and s.start > end:
169
+ break
170
+ who = f"{s.speaker}: " if s.speaker else ""
171
+ if words:
172
+ ws = " ".join(f"{w.text.strip()}@{w.start:.2f}" for w in s.words
173
+ if (start is None or w.end >= start) and (end is None or w.start <= end))
174
+ lines.append(f"[{fmt_time(s.start)}–{fmt_time(s.end)}] {who}{ws}")
175
+ else:
176
+ lines.append(f"[{fmt_time(s.start)}–{fmt_time(s.end)}] {who}{s.text.strip()}")
177
+ text = "\n".join(lines)
178
+ if len(text) > max_chars:
179
+ text = text[:max_chars] + f"\n… (truncated; ask for a time range, total {fmt_time(t.duration or 0)})"
180
+ return text
181
+
182
+
183
+ def split_ranges(values: Sequence[str]) -> list[Range]:
184
+ return [parse_range(v) for v in values]
185
+
186
+
187
+ def understand(
188
+ source: str,
189
+ language: str | None = None,
190
+ model: str = "small",
191
+ vocabulary: list[str] | str | None = None,
192
+ max_frames: int = 24,
193
+ backend: str = "faster-whisper",
194
+ force: bool = False,
195
+ progress=None,
196
+ ) -> dict:
197
+ """Everything a model needs to understand a file or link, in one call:
198
+ metadata, transcript with timestamps and (for video) a storyboard of shots.
199
+
200
+ Reuses the transcript/storyboard already on disk unless ``force``.
201
+ """
202
+ from .fetch import is_url, read_info, resolve
203
+ from .transcribe import transcribe
204
+
205
+ say = progress or (lambda _m: None)
206
+ if is_url(source):
207
+ say("opening the link (downloads the video the first time)…")
208
+ src = resolve(source)
209
+ info = probe(src)
210
+ meta = read_info(src)
211
+ result: dict = {"path": str(src), "info": info.to_dict(), "metadata": meta}
212
+
213
+ transcript = None
214
+ tpath = default_transcript_path(src)
215
+ if info.has_audio:
216
+ if tpath.exists() and not force:
217
+ transcript = Transcript.load(tpath)
218
+ else:
219
+ transcript = transcribe(src, backend=backend, model=model, language=language,
220
+ vocabulary=vocabulary, progress=say)
221
+ transcript.save(tpath)
222
+ result["transcript_path"] = str(tpath)
223
+ result["transcript"] = transcript
224
+
225
+ if info.has_video:
226
+ from .video import storyboard
227
+
228
+ out = Path(src).with_name(Path(src).stem + ".storyboard")
229
+ sb_json = out / "storyboard.json"
230
+ cached = None
231
+ if sb_json.exists() and not force:
232
+ import json
233
+
234
+ cached = json.loads(sb_json.read_text(encoding="utf-8"))
235
+ # a storyboard made before the transcript existed has no speech: rebuild it
236
+ if transcript is not None and not any(s.get("speech") for s in cached.get("shots", [])):
237
+ cached = None
238
+ if cached:
239
+ result["storyboard"] = cached
240
+ else:
241
+ say("picking key frames…")
242
+ result["storyboard"] = storyboard(src, out, transcript, max_frames=max_frames)
243
+ result["storyboard_dir"] = str(out)
244
+ return result
245
+
246
+
247
+ def understand_report(result: dict, max_chars: int = 15000) -> str:
248
+ """Model-friendly text version of :func:`understand`."""
249
+ from .models import fmt_time
250
+
251
+ info = result["info"]
252
+ lines = [f"file: {result['path']}"]
253
+ kind = "video" if info["has_video"] else "audio"
254
+ extra = f" · {info['width']}x{info['height']}" if info["has_video"] else ""
255
+ lines.append(f"{kind} · {fmt_time(info['duration'])}{extra}")
256
+ meta = result.get("metadata") or {}
257
+ for key in ("title", "uploader", "webpage_url", "upload_date"):
258
+ if meta.get(key):
259
+ lines.append(f"{key}: {meta[key]}")
260
+ if meta.get("description"):
261
+ lines.append(f"description: {str(meta['description'])[:600]}")
262
+ t: Transcript | None = result.get("transcript")
263
+ if t is not None:
264
+ lines += ["", f"## Transcript (language: {t.language}, {len(t.words())} words)",
265
+ transcript_excerpt(t, max_chars=max_chars)]
266
+ elif not info["has_audio"]:
267
+ lines += ["", "(no audio track)"]
268
+ sb = result.get("storyboard")
269
+ if sb:
270
+ sheets = [str(Path(result["storyboard_dir"]) / name) for name in sb.get("contact_sheets", [])]
271
+ if sheets:
272
+ lines += ["", "## Key frames",
273
+ "Contact sheets (open these images to see the video; each tile is labelled #shot and time):"]
274
+ lines += [f"- {p}" for p in sheets]
275
+ lines += ["", f"## Shots ({len(sb['shots'])}; contact sheets show #number and start time)"]
276
+ for s in sb["shots"]:
277
+ lines.append(f"#{s['index']} {s['start_tc']}–{s['end_tc']}: {s['speech'] or '—'}")
278
+ return "\n".join(lines)