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 +5 -0
- mediasense/__main__.py +5 -0
- mediasense/_skill/SKILL.md +84 -0
- mediasense/api.py +278 -0
- mediasense/cli.py +372 -0
- mediasense/edit.py +411 -0
- mediasense/export.py +270 -0
- mediasense/fetch.py +204 -0
- mediasense/jobs.py +124 -0
- mediasense/mcp_server.py +480 -0
- mediasense/media.py +338 -0
- mediasense/models.py +239 -0
- mediasense/setup.py +264 -0
- mediasense/skill.py +55 -0
- mediasense/transcribe.py +514 -0
- mediasense/video.py +159 -0
- mediasense-0.4.1.dist-info/METADATA +263 -0
- mediasense-0.4.1.dist-info/RECORD +21 -0
- mediasense-0.4.1.dist-info/WHEEL +4 -0
- mediasense-0.4.1.dist-info/entry_points.txt +2 -0
- mediasense-0.4.1.dist-info/licenses/LICENSE +21 -0
mediasense/__init__.py
ADDED
mediasense/__main__.py
ADDED
|
@@ -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)
|