openmd-cli 0.1.0__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.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,14 @@
1
+ """Placeholder for a future FunASR SenseVoice local backend.
2
+
3
+ The V1 lane intentionally does not import FunASR at module import time. A real
4
+ provider can be added behind an explicit backend gate after local dependency
5
+ smoke testing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from openmd.adapters.local_audio.asr import MissingASRBackend
11
+
12
+
13
+ def create_backend() -> MissingASRBackend:
14
+ return MissingASRBackend("funasr.sensevoice")
@@ -0,0 +1,254 @@
1
+ """Local Qwen3-ASR backend.
2
+
3
+ Qwen3-ASR scored best on the Chinese benchmark but sends a whole file to the GPU at
4
+ once, which exhausts an 8 GB card on long audio. Audio is therefore cut at FunASR VAD
5
+ speech boundaries first; each line keeps the start time of its chunk. Heavy imports
6
+ happen only inside ``transcribe``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gc
12
+ import importlib.util
13
+ import os
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from openmd.adapters.local_audio.asr import ASRBackend, MissingASRBackend
20
+ from openmd.core.models import TranscriptResult
21
+ from openmd.core.local_config import qwen3_batch_size, validate_qwen3_batch_size
22
+ from openmd.core.secrets import sanitize_error_summary
23
+ from openmd.runtime.models import ModelNotFoundError, model_label, offline_model_loading, require_model, resolve_model
24
+ from openmd.runtime.tempdirs import owned_temporary_directory
25
+ from openmd.runtime.interpreters import run_interruptibly
26
+
27
+ DEFAULT_MODEL = "Qwen/Qwen3-ASR-1.7B"
28
+ DEFAULT_VAD_MODEL = "iic/speech_fsmn_vad_zh-cn-16k-common-pytorch"
29
+ DEFAULT_DEVICE = "cuda:0"
30
+ MAX_CHUNK_MS = 25_000
31
+ SAMPLE_RATE = 16_000
32
+
33
+
34
+ class Qwen3ASRBackend(ASRBackend):
35
+ name = "asr.qwen3"
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ model: str | None = None,
41
+ vad_model: str | None = None,
42
+ device: str | None = None,
43
+ language: str | None = None,
44
+ batch_size: int | None = None,
45
+ ) -> None:
46
+ self.model = model or os.getenv("OPENMD_QWEN3_MODEL") or DEFAULT_MODEL
47
+ self.vad_model = vad_model or os.getenv("OPENMD_QWEN3_VAD_MODEL") or DEFAULT_VAD_MODEL
48
+ self.device = device or os.getenv("OPENMD_QWEN3_DEVICE") or DEFAULT_DEVICE
49
+ self.language = language or os.getenv("OPENMD_QWEN3_LANGUAGE") or None
50
+ self.batch_size = qwen3_batch_size() if batch_size is None else validate_qwen3_batch_size(batch_size)
51
+ self._run_metadata: dict[str, Any] = {}
52
+
53
+ def execution_config(self) -> dict[str, Any]:
54
+ return {
55
+ "model": model_label(self.model),
56
+ "model_origin": resolve_model(self.model, family="qwen3").origin,
57
+ "vad_model": model_label(self.vad_model),
58
+ "vad_origin": resolve_model(self.vad_model, family="vad").origin,
59
+ "local_files_only": True,
60
+ "device": self.device,
61
+ "language": self.language,
62
+ "max_chunk_ms": MAX_CHUNK_MS,
63
+ "batch_size": self.batch_size,
64
+ }
65
+
66
+ def is_available(self) -> bool:
67
+ return all(importlib.util.find_spec(name) is not None for name in ("qwen_asr", "funasr", "soundfile"))
68
+
69
+ def transcribe(self, path: Path) -> TranscriptResult:
70
+ self._run_metadata = {}
71
+ source_path = path.expanduser()
72
+ if not source_path.is_file():
73
+ return _failed(self.name, "asr_file_not_found", f"Local audio file not found: {source_path.name}")
74
+ if not self.is_available():
75
+ return MissingASRBackend(self.name).transcribe(source_path)
76
+
77
+ failure: TranscriptResult | None = None
78
+ try:
79
+ lines, languages, duration = self._run(source_path)
80
+ except ModelNotFoundError as exc:
81
+ failure = _failed(self.name, "asr_model_missing", exc)
82
+ except Exception as exc: # pragma: no cover - dependency/model safety net
83
+ failure = _failed(self.name, "asr_extraction_failed", exc)
84
+ if failure is not None:
85
+ # The exception and its model-owning traceback have now been released.
86
+ # This also permits asr.auto's Whisper fallback in the same process.
87
+ gc.collect()
88
+ torch = sys.modules.get("torch")
89
+ if torch is not None and torch.cuda.is_available():
90
+ torch.cuda.empty_cache()
91
+ failure.metadata.update(self._run_metadata)
92
+ return failure
93
+
94
+ if not lines:
95
+ failure = _failed(self.name, "asr_empty_text", "Qwen3-ASR returned no recognized text.")
96
+ failure.metadata.update(self._run_metadata)
97
+ return failure
98
+ return TranscriptResult(
99
+ text="\n".join(lines) + "\n",
100
+ status="verified",
101
+ source_level="asr",
102
+ backend=self.name,
103
+ source_notes=[
104
+ "Qwen3-ASR transcribed a local audio file in VAD-bounded chunks.",
105
+ "Each timestamp is the start of a chunk, not of a sentence.",
106
+ "Raw audio file was referenced, not copied into the package.",
107
+ *(["Native Qwen3-ASR batching may change individual words or punctuation through numerical differences; set OPENMD_QWEN3_BATCH_SIZE=1 for batch-1 reproducibility."] if self.batch_size > 1 else []),
108
+ ],
109
+ title=source_path.stem or "local audio",
110
+ metadata={
111
+ "profile": "media",
112
+ "asr_backend": self.name,
113
+ "model": model_label(self.model),
114
+ "device": self.device,
115
+ "language": self.language or (languages[0] if len(set(languages)) == 1 else "mixed"),
116
+ "duration": duration,
117
+ "segment_count": len(lines),
118
+ **self._run_metadata,
119
+ },
120
+ )
121
+
122
+ def _run(self, path: Path) -> tuple[list[str], list[str], float]:
123
+ started = time.monotonic()
124
+ model_path = require_model(self.model, family="qwen3")
125
+ vad_path = require_model(self.vad_model, family="vad")
126
+ import soundfile
127
+ import torch
128
+ from funasr import AutoModel
129
+ from qwen_asr import Qwen3ASRModel
130
+
131
+ with owned_temporary_directory(prefix="openmd-qwen3-") as tmp:
132
+ wav = Path(tmp) / "audio.wav"
133
+ run_interruptibly(
134
+ ["ffmpeg", "-v", "error", "-y", "-i", str(path), "-ar", str(SAMPLE_RATE), "-ac", "1", str(wav)],
135
+ check=True,
136
+ capture_output=True,
137
+ )
138
+ audio, _ = soundfile.read(str(wav), dtype="float32")
139
+ with offline_model_loading():
140
+ vad = AutoModel(model=str(vad_path), device=self.device, disable_update=True)
141
+ spans = merge_speech_spans(vad.generate(input=str(wav))[0]["value"], MAX_CHUNK_MS)
142
+ del vad
143
+ with offline_model_loading():
144
+ model = Qwen3ASRModel.from_pretrained(
145
+ str(model_path),
146
+ dtype=torch.bfloat16,
147
+ device_map=self.device,
148
+ max_inference_batch_size=self.batch_size,
149
+ max_new_tokens=512,
150
+ local_files_only=True,
151
+ )
152
+ try:
153
+ lines, languages = self._transcribe_spans(model, audio, spans, torch)
154
+ return lines, languages, len(audio) / SAMPLE_RATE
155
+ finally:
156
+ del model
157
+ gc.collect()
158
+ if torch.cuda.is_available():
159
+ torch.cuda.empty_cache()
160
+ self._run_metadata["elapsed_seconds"] = round(time.monotonic() - started, 6)
161
+
162
+ def _transcribe_spans(
163
+ self, model: Any, audio: Any, spans: list[list[int]], torch: Any,
164
+ ) -> tuple[list[str], list[str]]:
165
+ """Batch existing VAD chunks without changing their order or timestamps.
166
+
167
+ Only a CUDA OOM retries, and only the slice not yet appended. Leaving the
168
+ exception handler before cache cleanup releases the failed call's frames.
169
+ """
170
+ lines: list[str] = []
171
+ languages: list[str] = []
172
+ effective = self.batch_size
173
+ histogram: dict[str, int] = {}
174
+ oom_attempts: list[int] = []
175
+ retries = 0
176
+ inference_seconds = 0.0
177
+ index = 0
178
+ try:
179
+ while index < len(spans):
180
+ current = spans[index:index + effective]
181
+ pieces = [audio[start * SAMPLE_RATE // 1000:end * SAMPLE_RATE // 1000] for start, end in current]
182
+ # Preserve the original tuple call when inference is scalar.
183
+ inputs = (pieces[0], SAMPLE_RATE) if effective == 1 else [(piece, SAMPLE_RATE) for piece in pieces]
184
+ started = time.monotonic()
185
+ retry = False
186
+ try:
187
+ results = model.transcribe(audio=inputs, language=self.language)
188
+ except torch.cuda.OutOfMemoryError:
189
+ oom_attempts.append(len(current))
190
+ if effective == 1:
191
+ raise
192
+ effective = max(1, effective // 2)
193
+ model.max_inference_batch_size = effective
194
+ retries += 1
195
+ retry = True
196
+ finally:
197
+ inference_seconds += time.monotonic() - started
198
+ if retry:
199
+ gc.collect()
200
+ torch.cuda.empty_cache()
201
+ continue
202
+ if len(results) != len(current):
203
+ raise ValueError(f"qwen3_batch_cardinality_mismatch: expected {len(current)}, got {len(results)}")
204
+ for (start, _), result in zip(current, results):
205
+ text = str(result.text).strip()
206
+ if text:
207
+ lines.append(f"[{format_timestamp(start)}] {text}")
208
+ languages.append(str(getattr(result, "language", "") or "unknown"))
209
+ key = str(len(current))
210
+ histogram[key] = histogram.get(key, 0) + 1
211
+ index += len(current)
212
+ finally:
213
+ self._run_metadata.update({
214
+ "batch_size_requested": self.batch_size,
215
+ "batch_size_effective": effective,
216
+ "batch_size_histogram": histogram,
217
+ "batch_oom_retries": retries,
218
+ "batch_oom_attempts": oom_attempts,
219
+ "inference_seconds": round(inference_seconds, 6),
220
+ })
221
+ return lines, languages
222
+
223
+
224
+ def merge_speech_spans(spans: list[list[int]], limit_ms: int) -> list[list[int]]:
225
+ """Join neighbouring VAD spans while the merged chunk stays within ``limit_ms``."""
226
+ merged: list[list[int]] = []
227
+ for start, end in spans:
228
+ if merged and end - merged[-1][0] <= limit_ms:
229
+ merged[-1][1] = end
230
+ else:
231
+ merged.append([start, end])
232
+ return merged
233
+
234
+
235
+ def format_timestamp(milliseconds: int) -> str:
236
+ seconds = milliseconds // 1000
237
+ return f"{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"
238
+
239
+
240
+ def _failed(backend: str, code: str, error: object) -> TranscriptResult:
241
+ return TranscriptResult(
242
+ text="",
243
+ status="failed",
244
+ source_level="partial_extraction",
245
+ backend=backend,
246
+ source_notes=["Qwen3-ASR did not produce usable text."],
247
+ error_code=code,
248
+ error_summary=sanitize_error_summary(error),
249
+ metadata={"profile": "media", "asr_backend": backend},
250
+ )
251
+
252
+
253
+ def create_backend() -> Qwen3ASRBackend:
254
+ return Qwen3ASRBackend()