@tienne/gestalt 0.107.0 → 0.109.0

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 (219) hide show
  1. package/CLAUDE.md +2 -0
  2. package/README.ko.md +1 -0
  3. package/README.md +1 -0
  4. package/dist/package.json +1 -1
  5. package/dist/plugin/role-agents/lens-context/AGENT.md +94 -0
  6. package/dist/plugin/role-agents/lens-link/AGENT.md +83 -0
  7. package/dist/plugin/role-agents/lens-reader/AGENT.md +230 -0
  8. package/dist/plugin/skills/_shared/proactive-routing.md +1 -0
  9. package/dist/plugin/skills/lens/SKILL.md +375 -0
  10. package/dist/schemas/gestalt.schema.json +13 -0
  11. package/dist/src/cli/commands/digest.d.ts +2 -0
  12. package/dist/src/cli/commands/digest.d.ts.map +1 -1
  13. package/dist/src/cli/commands/digest.js +6 -0
  14. package/dist/src/cli/commands/digest.js.map +1 -1
  15. package/dist/src/cli/commands/lens.d.ts +27 -0
  16. package/dist/src/cli/commands/lens.d.ts.map +1 -0
  17. package/dist/src/cli/commands/lens.js +200 -0
  18. package/dist/src/cli/commands/lens.js.map +1 -0
  19. package/dist/src/cli/commands/peer.d.ts +19 -0
  20. package/dist/src/cli/commands/peer.d.ts.map +1 -0
  21. package/dist/src/cli/commands/peer.js +147 -0
  22. package/dist/src/cli/commands/peer.js.map +1 -0
  23. package/dist/src/cli/index.d.ts.map +1 -1
  24. package/dist/src/cli/index.js +72 -2
  25. package/dist/src/cli/index.js.map +1 -1
  26. package/dist/src/digest/format.d.ts.map +1 -1
  27. package/dist/src/digest/format.js +1 -0
  28. package/dist/src/digest/format.js.map +1 -1
  29. package/dist/src/digest/frames.d.ts.map +1 -1
  30. package/dist/src/digest/frames.js +1 -1
  31. package/dist/src/digest/frames.js.map +1 -1
  32. package/dist/src/digest/ingest.d.ts +8 -1
  33. package/dist/src/digest/ingest.d.ts.map +1 -1
  34. package/dist/src/digest/ingest.js +2 -1
  35. package/dist/src/digest/ingest.js.map +1 -1
  36. package/dist/src/digest/lens-schema.d.ts +542 -0
  37. package/dist/src/digest/lens-schema.d.ts.map +1 -0
  38. package/dist/src/digest/lens-schema.js +108 -0
  39. package/dist/src/digest/lens-schema.js.map +1 -0
  40. package/dist/src/digest/lens-source.d.ts +260 -0
  41. package/dist/src/digest/lens-source.d.ts.map +1 -0
  42. package/dist/src/digest/lens-source.js +92 -0
  43. package/dist/src/digest/lens-source.js.map +1 -0
  44. package/dist/src/digest/lens-widget-schema.d.ts +1225 -0
  45. package/dist/src/digest/lens-widget-schema.d.ts.map +1 -0
  46. package/dist/src/digest/lens-widget-schema.js +80 -0
  47. package/dist/src/digest/lens-widget-schema.js.map +1 -0
  48. package/dist/src/digest/lens-widgets.d.ts +25 -0
  49. package/dist/src/digest/lens-widgets.d.ts.map +1 -0
  50. package/dist/src/digest/lens-widgets.js +225 -0
  51. package/dist/src/digest/lens-widgets.js.map +1 -0
  52. package/dist/src/digest/lint-lens-context.d.ts +102 -0
  53. package/dist/src/digest/lint-lens-context.d.ts.map +1 -0
  54. package/dist/src/digest/lint-lens-context.js +192 -0
  55. package/dist/src/digest/lint-lens-context.js.map +1 -0
  56. package/dist/src/digest/lint-lens.d.ts +17 -0
  57. package/dist/src/digest/lint-lens.d.ts.map +1 -0
  58. package/dist/src/digest/lint-lens.js +237 -0
  59. package/dist/src/digest/lint-lens.js.map +1 -0
  60. package/dist/src/digest/lint.d.ts +14 -0
  61. package/dist/src/digest/lint.d.ts.map +1 -1
  62. package/dist/src/digest/lint.js +156 -2
  63. package/dist/src/digest/lint.js.map +1 -1
  64. package/dist/src/digest/merge.d.ts.map +1 -1
  65. package/dist/src/digest/merge.js +11 -2
  66. package/dist/src/digest/merge.js.map +1 -1
  67. package/dist/src/digest/prose.d.ts.map +1 -1
  68. package/dist/src/digest/prose.js +8 -0
  69. package/dist/src/digest/prose.js.map +1 -1
  70. package/dist/src/digest/render-html.d.ts +5 -0
  71. package/dist/src/digest/render-html.d.ts.map +1 -1
  72. package/dist/src/digest/render-html.js +93 -18
  73. package/dist/src/digest/render-html.js.map +1 -1
  74. package/dist/src/digest/render-lens.d.ts +61 -0
  75. package/dist/src/digest/render-lens.d.ts.map +1 -0
  76. package/dist/src/digest/render-lens.js +273 -0
  77. package/dist/src/digest/render-lens.js.map +1 -0
  78. package/dist/src/digest/render-md.d.ts +5 -0
  79. package/dist/src/digest/render-md.d.ts.map +1 -1
  80. package/dist/src/digest/render-md.js +116 -7
  81. package/dist/src/digest/render-md.js.map +1 -1
  82. package/dist/src/digest/render-widgets.d.ts +13 -0
  83. package/dist/src/digest/render-widgets.d.ts.map +1 -0
  84. package/dist/src/digest/render-widgets.js +197 -0
  85. package/dist/src/digest/render-widgets.js.map +1 -0
  86. package/dist/src/digest/render.d.ts +2 -0
  87. package/dist/src/digest/render.d.ts.map +1 -1
  88. package/dist/src/digest/render.js +17 -3
  89. package/dist/src/digest/render.js.map +1 -1
  90. package/dist/src/digest/schema.d.ts +14683 -2022
  91. package/dist/src/digest/schema.d.ts.map +1 -1
  92. package/dist/src/digest/schema.js +67 -6
  93. package/dist/src/digest/schema.js.map +1 -1
  94. package/dist/src/digest/server.d.ts +2 -1
  95. package/dist/src/digest/server.d.ts.map +1 -1
  96. package/dist/src/digest/server.js +26 -11
  97. package/dist/src/digest/server.js.map +1 -1
  98. package/dist/src/digest/viewer-js.d.ts.map +1 -1
  99. package/dist/src/digest/viewer-js.js +120 -4
  100. package/dist/src/digest/viewer-js.js.map +1 -1
  101. package/dist/src/lens/attach.d.ts +436 -0
  102. package/dist/src/lens/attach.d.ts.map +1 -0
  103. package/dist/src/lens/attach.js +46 -0
  104. package/dist/src/lens/attach.js.map +1 -0
  105. package/dist/src/lens/ingest.d.ts +75 -0
  106. package/dist/src/lens/ingest.d.ts.map +1 -0
  107. package/dist/src/lens/ingest.js +558 -0
  108. package/dist/src/lens/ingest.js.map +1 -0
  109. package/dist/src/lens/kind.d.ts +11 -0
  110. package/dist/src/lens/kind.d.ts.map +1 -0
  111. package/dist/src/lens/kind.js +59 -0
  112. package/dist/src/lens/kind.js.map +1 -0
  113. package/dist/src/lens/knowledge.d.ts +53 -0
  114. package/dist/src/lens/knowledge.d.ts.map +1 -0
  115. package/dist/src/lens/knowledge.js +202 -0
  116. package/dist/src/lens/knowledge.js.map +1 -0
  117. package/dist/src/lens/notes.d.ts +73 -0
  118. package/dist/src/lens/notes.d.ts.map +1 -0
  119. package/dist/src/lens/notes.js +276 -0
  120. package/dist/src/lens/notes.js.map +1 -0
  121. package/dist/src/lens/panels.d.ts +10 -0
  122. package/dist/src/lens/panels.d.ts.map +1 -0
  123. package/dist/src/lens/panels.js +25 -0
  124. package/dist/src/lens/panels.js.map +1 -0
  125. package/dist/src/lens/pdf-extract-py.d.ts +15 -0
  126. package/dist/src/lens/pdf-extract-py.d.ts.map +1 -0
  127. package/dist/src/lens/pdf-extract-py.js +414 -0
  128. package/dist/src/lens/pdf-extract-py.js.map +1 -0
  129. package/dist/src/lens/podcast.d.ts +36 -0
  130. package/dist/src/lens/podcast.d.ts.map +1 -0
  131. package/dist/src/lens/podcast.js +104 -0
  132. package/dist/src/lens/podcast.js.map +1 -0
  133. package/dist/src/lens/python.d.ts +33 -0
  134. package/dist/src/lens/python.d.ts.map +1 -0
  135. package/dist/src/lens/python.js +61 -0
  136. package/dist/src/lens/python.js.map +1 -0
  137. package/dist/src/lens/web-extract-py.d.ts +9 -0
  138. package/dist/src/lens/web-extract-py.d.ts.map +1 -0
  139. package/dist/src/lens/web-extract-py.js +114 -0
  140. package/dist/src/lens/web-extract-py.js.map +1 -0
  141. package/dist/src/mcp/schemas.d.ts +3 -0
  142. package/dist/src/mcp/schemas.d.ts.map +1 -1
  143. package/dist/src/mcp/schemas.js +4 -0
  144. package/dist/src/mcp/schemas.js.map +1 -1
  145. package/dist/src/mcp/server.js +1 -1
  146. package/dist/src/mcp/server.js.map +1 -1
  147. package/dist/src/mcp/tools/digest-passthrough.d.ts.map +1 -1
  148. package/dist/src/mcp/tools/digest-passthrough.js +5 -0
  149. package/dist/src/mcp/tools/digest-passthrough.js.map +1 -1
  150. package/dist/src/peer/client.d.ts +9 -0
  151. package/dist/src/peer/client.d.ts.map +1 -0
  152. package/dist/src/peer/client.js +51 -0
  153. package/dist/src/peer/client.js.map +1 -0
  154. package/dist/src/peer/daemon.d.ts +71 -0
  155. package/dist/src/peer/daemon.d.ts.map +1 -0
  156. package/dist/src/peer/daemon.js +407 -0
  157. package/dist/src/peer/daemon.js.map +1 -0
  158. package/dist/src/peer/errors.d.ts +2 -0
  159. package/dist/src/peer/errors.d.ts.map +1 -0
  160. package/dist/src/peer/errors.js +4 -0
  161. package/dist/src/peer/errors.js.map +1 -0
  162. package/dist/src/peer/index.d.ts +14 -0
  163. package/dist/src/peer/index.d.ts.map +1 -0
  164. package/dist/src/peer/index.js +9 -0
  165. package/dist/src/peer/index.js.map +1 -0
  166. package/dist/src/peer/locate.d.ts +44 -0
  167. package/dist/src/peer/locate.d.ts.map +1 -0
  168. package/dist/src/peer/locate.js +116 -0
  169. package/dist/src/peer/locate.js.map +1 -0
  170. package/dist/src/peer/paths.d.ts +23 -0
  171. package/dist/src/peer/paths.d.ts.map +1 -0
  172. package/dist/src/peer/paths.js +50 -0
  173. package/dist/src/peer/paths.js.map +1 -0
  174. package/dist/src/peer/protocol.d.ts +61 -0
  175. package/dist/src/peer/protocol.d.ts.map +1 -0
  176. package/dist/src/peer/protocol.js +7 -0
  177. package/dist/src/peer/protocol.js.map +1 -0
  178. package/dist/src/peer/redact.d.ts +30 -0
  179. package/dist/src/peer/redact.d.ts.map +1 -0
  180. package/dist/src/peer/redact.js +214 -0
  181. package/dist/src/peer/redact.js.map +1 -0
  182. package/dist/src/peer/share.d.ts +123 -0
  183. package/dist/src/peer/share.d.ts.map +1 -0
  184. package/dist/src/peer/share.js +204 -0
  185. package/dist/src/peer/share.js.map +1 -0
  186. package/dist/src/peer/state.d.ts +37 -0
  187. package/dist/src/peer/state.d.ts.map +1 -0
  188. package/dist/src/peer/state.js +99 -0
  189. package/dist/src/peer/state.js.map +1 -0
  190. package/dist/src/peer/tail.d.ts +55 -0
  191. package/dist/src/peer/tail.d.ts.map +1 -0
  192. package/dist/src/peer/tail.js +148 -0
  193. package/dist/src/peer/tail.js.map +1 -0
  194. package/dist/src/peer/transform.d.ts +79 -0
  195. package/dist/src/peer/transform.d.ts.map +1 -0
  196. package/dist/src/peer/transform.js +237 -0
  197. package/dist/src/peer/transform.js.map +1 -0
  198. package/dist/src/peer/viewer-html.d.ts +10 -0
  199. package/dist/src/peer/viewer-html.d.ts.map +1 -0
  200. package/dist/src/peer/viewer-html.js +157 -0
  201. package/dist/src/peer/viewer-html.js.map +1 -0
  202. package/dist/src/peer/viewer.d.ts +58 -0
  203. package/dist/src/peer/viewer.d.ts.map +1 -0
  204. package/dist/src/peer/viewer.js +249 -0
  205. package/dist/src/peer/viewer.js.map +1 -0
  206. package/dist/src/research/source-create.d.ts +6 -1
  207. package/dist/src/research/source-create.d.ts.map +1 -1
  208. package/dist/src/research/source-create.js +41 -10
  209. package/dist/src/research/source-create.js.map +1 -1
  210. package/package.json +1 -1
  211. package/plugin/.codex-plugin/plugin.json +1 -1
  212. package/plugin/.mcp.json +1 -1
  213. package/plugin/mcp.json +1 -1
  214. package/plugin/role-agents/lens-context/AGENT.md +94 -0
  215. package/plugin/role-agents/lens-link/AGENT.md +83 -0
  216. package/plugin/role-agents/lens-reader/AGENT.md +230 -0
  217. package/plugin/skills/_shared/proactive-routing.md +1 -0
  218. package/plugin/skills/lens/SKILL.md +375 -0
  219. package/schemas/gestalt.schema.json +13 -0
@@ -0,0 +1,414 @@
1
+ /**
2
+ * PDF에서 문단과 표, 그림을 좌표와 함께 뽑는 파이썬 스크립트. pdfplumber를 쓴다(pypdfium2로 쪽 그림을 그린다).
3
+ *
4
+ * - 문단: 단을 나눠 읽고 줄 간격과 들여쓰기로 끊는다. 단이나 쪽을 넘는 문단은 사각형을 여럿 단다.
5
+ * 아래첨자 줄은 버리고 수식 조각은 앞 문단에 붙인다.
6
+ * - 표: "Table N:" 캡션과 같은 단에서 가장 가까운 가로줄 묶음(booktabs)을 표 자리로 잡는다.
7
+ * 모든 줄에서 비어 있는 세로 띠를 칸 경계로 본다. 칸이 하나뿐이거나 빈칸이 30%를 넘으면 grid를 loose로 적는다.
8
+ * - 그림: "Figure N:" 캡션 위나 아래의 비트맵을 묶어 자르고 벡터 그림은 캡션 위 빈자리를 자른다.
9
+ *
10
+ * 결과 JSON은 stdout 마지막 줄 하나로 낸다. 쪽 그림과 크롭은 두 번째 인자 폴더의 pages/, figures/, tables/에 쓴다.
11
+ * 세 번째 인자는 쪽 수 상한이다. 넘으면 stderr에 too-many-pages를 남기고 멈춘다.
12
+ * 백틱과 달러 중괄호가 없어야 String.raw 안에 그대로 실린다
13
+ */
14
+ export const PDF_EXTRACT_PY = String.raw `import json, os, re, statistics, sys
15
+
16
+ # 신뢰할 수 없는 PDF가 메모리를 다 먹지 않게 주소 공간을 4GB로 묶는다. macOS는 RLIMIT_AS를 거부해서 Linux에서만 건다
17
+ if sys.platform.startswith("linux"):
18
+ try:
19
+ import resource
20
+ _, hard = resource.getrlimit(resource.RLIMIT_AS)
21
+ if hard == resource.RLIM_INFINITY or hard > (4 << 30):
22
+ resource.setrlimit(resource.RLIMIT_AS, (4 << 30, hard))
23
+ except (ImportError, OSError, ValueError):
24
+ pass
25
+
26
+ import pdfplumber
27
+
28
+ pdf_path, out_dir = sys.argv[1], sys.argv[2]
29
+ MAX_PAGES = int(sys.argv[3]) if len(sys.argv) > 3 else 300
30
+ PAGE_SCALE = 2.0
31
+ CROP_SCALE = 3.0
32
+ CAPTION = re.compile(r"^(Table|Figure|Fig\.)\s*(\d+)\s*[:.]", re.I)
33
+ NUM = re.compile(r"^[-+]?\d")
34
+
35
+
36
+ def cluster_lines(words, tol=2.0, gap=15.0):
37
+ words = sorted(words, key=lambda w: (round(w["top"] / tol), w["x0"]))
38
+ rows = []
39
+ for w in sorted(words, key=lambda w: w["top"]):
40
+ for r in rows:
41
+ if abs(r["top"] - w["top"]) <= tol:
42
+ r["words"].append(w)
43
+ break
44
+ else:
45
+ rows.append({"top": w["top"], "words": [w]})
46
+ segs = []
47
+ for r in rows:
48
+ ws = sorted(r["words"], key=lambda w: w["x0"])
49
+ cur = [ws[0]]
50
+ for w in ws[1:]:
51
+ if w["x0"] - cur[-1]["x1"] > gap:
52
+ segs.append(cur)
53
+ cur = [w]
54
+ else:
55
+ cur.append(w)
56
+ segs.append(cur)
57
+ out = []
58
+ for ws in segs:
59
+ out.append(
60
+ {
61
+ "x0": min(w["x0"] for w in ws),
62
+ "x1": max(w["x1"] for w in ws),
63
+ "top": min(w["top"] for w in ws),
64
+ "bottom": max(w["bottom"] for w in ws),
65
+ "size": statistics.median(w["size"] for w in ws),
66
+ "bold": sum(1 for w in ws if re.search(r"Bold|Medi|Semibold", w["fontname"])) > len(ws) / 2,
67
+ "text": " ".join(w["text"] for w in ws),
68
+ }
69
+ )
70
+ return out
71
+
72
+
73
+ def inside(line, box, pad=2):
74
+ x0, top, x1, bottom = box
75
+ return line["x0"] >= x0 - pad and line["x1"] <= x1 + pad and line["top"] >= top - pad and line["bottom"] <= bottom + pad
76
+
77
+
78
+ def overlap_x(a0, a1, b0, b1):
79
+ return max(0.0, min(a1, b1) - max(a0, b0))
80
+
81
+
82
+ def table_regions(page, captions):
83
+ """Booktabs 표는 가로줄 묶음으로 자리를 잡는다. 캡션과 같은 단에서 가장 가까운 묶음이다"""
84
+ rules = sorted(
85
+ [l for l in page.lines if abs(l["top"] - l["bottom"]) < 1.5 and l["x1"] - l["x0"] > 40],
86
+ key=lambda l: l["top"],
87
+ )
88
+ groups = []
89
+ for l in rules:
90
+ for g in groups:
91
+ if overlap_x(g["x0"], g["x1"], l["x0"], l["x1"]) > 0.8 * (l["x1"] - l["x0"]) and l["top"] - g["bottom"] < 90:
92
+ g["bottom"] = max(g["bottom"], l["top"])
93
+ g["n"] += 1
94
+ g["ys"].append((l["top"], l["x1"] - l["x0"]))
95
+ break
96
+ else:
97
+ groups.append({"x0": l["x0"], "x1": l["x1"], "top": l["top"], "bottom": l["top"], "n": 1, "ys": [(l["top"], l["x1"] - l["x0"])]})
98
+ groups = [g for g in groups if g["n"] >= 2]
99
+ found = []
100
+ used = set()
101
+ for cap in captions:
102
+ best, best_d = None, 1e9
103
+ for gi, g in enumerate(groups):
104
+ if gi in used or overlap_x(g["x0"], g["x1"], cap["x0"], cap["x1"]) <= 0:
105
+ continue
106
+ d = g["top"] - cap["bottom"] if g["top"] >= cap["top"] else cap["top"] - g["bottom"]
107
+ if 0 <= d < best_d and d < 120:
108
+ best, best_d = gi, d
109
+ if best is not None:
110
+ used.add(best)
111
+ g = groups[best]
112
+ # 머리줄 경계는 표 폭 전체를 긋는 줄만 본다. 여러 열을 묶는 짧은 줄(cmidrule)은 머리줄 안쪽이다
113
+ full = sorted(y for y, w in g["ys"] if w >= 0.9 * (g["x1"] - g["x0"]))
114
+ found.append((cap, (g["x0"] - 2, g["top"] - 1, g["x1"] + 2, g["bottom"] + 1), full))
115
+ return found
116
+
117
+
118
+ def build_table(page, box, ys):
119
+ words = page.crop(box).extract_words(x_tolerance=1.5, extra_attrs=["size", "fontname"])
120
+ if not words:
121
+ return None
122
+ rows = []
123
+ for w in sorted(words, key=lambda w: (w["top"] + w["bottom"]) / 2):
124
+ mid = (w["top"] + w["bottom"]) / 2
125
+ for r in rows:
126
+ if abs(r["mid"] - mid) <= 3.5:
127
+ r["words"].append(w)
128
+ break
129
+ else:
130
+ rows.append({"mid": mid, "words": [w]})
131
+ rows.sort(key=lambda r: r["mid"])
132
+ # booktabs는 윗줄과 가운데 줄 사이가 머리줄이다. 머리줄이 여러 줄이면 칸끼리 이어 한 줄로 합친다
133
+ head_n = sum(1 for r in rows if r["mid"] < ys[1]) if len(ys) >= 3 else 0
134
+ body_words = [w for r in rows[head_n:] for w in r["words"]] or words
135
+ # 본문 줄에서 비어 있는 세로 띠가 칸 경계다. 여러 칸에 걸친 머리줄 글자가 경계를 지우지 않게 머리줄은 빼고 본다
136
+ x0 = min(w["x0"] for w in body_words)
137
+ x1 = max(w["x1"] for w in body_words)
138
+ step = 0.5
139
+ n = int((x1 - x0) / step) + 1
140
+ covered = [False] * n
141
+ for w in body_words:
142
+ for k in range(int((w["x0"] - x0) / step), min(n, int((w["x1"] - x0) / step) + 1)):
143
+ covered[k] = True
144
+ bounds = []
145
+ k = 0
146
+ while k < n:
147
+ if not covered[k]:
148
+ j = k
149
+ while j < n and not covered[j]:
150
+ j += 1
151
+ if (j - k) * step >= 4.0:
152
+ bounds.append(x0 + (k + j) / 2 * step)
153
+ k = j
154
+ else:
155
+ k += 1
156
+ # 머리줄 글자가 본문 폭 밖으로 나가도 양 끝 칸에 들어가게 바깥 경계는 열어 둔다
157
+ edges = [float("-inf")] + bounds + [float("inf")]
158
+ # 본문 칸마다 글자가 실제로 차지한 폭. 머리줄 낱말이 어느 칸 글자 위에 있는지 볼 때 쓴다
159
+ extents = {}
160
+ for w in body_words:
161
+ cx = (w["x0"] + w["x1"]) / 2
162
+ c = next(c for c in range(len(edges) - 1) if edges[c] <= cx < edges[c + 1])
163
+ lo, hi = extents.get(c, (w["x0"], w["x1"]))
164
+ extents[c] = (min(lo, w["x0"]), max(hi, w["x1"]))
165
+ grid = []
166
+ for ri, r in enumerate(rows):
167
+ cells = [[] for _ in range(len(edges) - 1)]
168
+ ws = sorted(r["words"], key=lambda w: w["x0"])
169
+ # 머리줄 낱말이 칸 경계에 걸치면 본문 칸의 실제 폭과 겹치는 칸으로 보낸다. 두 칸 사이 빈 띠 안이면
170
+ # 더 가까운 옆 낱말의 칸을 따른다("Train Size"의 Size). 머리 글자가 본문 칸보다 넓어도 제 칸에 남는다
171
+ def anchor(k):
172
+ w = ws[k]
173
+ mid = (w["x0"] + w["x1"]) / 2
174
+ if ri >= head_n or not any(w["x0"] < b < w["x1"] for b in bounds):
175
+ return mid
176
+ hits = sorted(c for c, (a, b) in extents.items() if min(w["x1"], b) - max(w["x0"], a) > 0)
177
+ # 여러 칸에 걸친 머리 글자("BLEU")는 묶는 칸들 가운데 첫 칸에 둔다
178
+ if hits:
179
+ lo, hi = extents[hits[0]]
180
+ return (max(lo, w["x0"]) + min(hi, w["x1"])) / 2
181
+ left = ws[k - 1] if k > 0 else None
182
+ right = ws[k + 1] if k + 1 < len(ws) else None
183
+ gl = w["x0"] - left["x1"] if left else float("inf")
184
+ gr = right["x0"] - w["x1"] if right else float("inf")
185
+ near = left if gl <= gr else right
186
+ return (near["x0"] + near["x1"]) / 2 if near else mid
187
+ placed = [(anchor(k), w["text"]) for k, w in enumerate(ws)]
188
+ for cx, text in placed:
189
+ for c in range(len(edges) - 1):
190
+ if edges[c] <= cx < edges[c + 1]:
191
+ cells[c].append(text)
192
+ break
193
+ grid.append([re.sub(r"(?<=\d) ?\. ?(?=\d)", ".", " ".join(c)) for c in cells])
194
+ if head_n > 1:
195
+ head = [" ".join(grid[r][c] for r in range(head_n) if grid[r][c]) for c in range(len(edges) - 1)]
196
+ grid = [head] + grid[head_n:]
197
+ return grid
198
+
199
+
200
+ def figure_region(page, cap, text_lines):
201
+ imgs = [im for im in page.images if overlap_x(im["x0"], im["x1"], cap["x0"], cap["x1"]) > 0 or cap["x1"] - cap["x0"] > page.width * 0.6]
202
+ above = [im for im in imgs if im["bottom"] <= cap["top"] + 4 and cap["top"] - im["bottom"] < 80]
203
+ if above:
204
+ near = max(im["bottom"] for im in above)
205
+ group = [im for im in above if near - im["bottom"] < 20]
206
+ return (min(i["x0"] for i in group), min(i["top"] for i in group), max(i["x1"] for i in group), max(i["bottom"] for i in group))
207
+ below = [im for im in imgs if im["top"] >= cap["bottom"] - 4 and im["top"] - cap["bottom"] < 80]
208
+ if below:
209
+ near = min(im["top"] for im in below)
210
+ group = [im for im in below if im["top"] - near < 20]
211
+ return (min(i["x0"] for i in group), min(i["top"] for i in group), max(i["x1"] for i in group), max(i["bottom"] for i in group))
212
+ # 벡터 그림은 캡션 위로 같은 단의 본문이 끝나는 자리까지를 잡는다
213
+ col = [l for l in text_lines if overlap_x(l["x0"], l["x1"], cap["x0"], cap["x1"]) > 0 and l["bottom"] < cap["top"] - 2]
214
+ top = max([l["bottom"] for l in col if cap["top"] - l["bottom"] > 30] or [page.bbox[1] + 50])
215
+ if cap["top"] - top < 30:
216
+ return None
217
+ return (cap["x0"], top + 2, cap["x1"], cap["top"] - 2)
218
+
219
+
220
+ ENDS = re.compile(r"[.?!:]['\")\]]?$")
221
+
222
+
223
+ def join_text(lines):
224
+ text = ""
225
+ for l in lines:
226
+ t = l["text"].strip()
227
+ if not text:
228
+ text = t
229
+ elif re.search(r"[A-Za-z]-$", text) and re.match(r"^[a-z]", t):
230
+ text = text[:-1] + t
231
+ else:
232
+ text += " " + t
233
+ return text
234
+
235
+
236
+ def main():
237
+ os.makedirs(os.path.join(out_dir, "pages"), exist_ok=True)
238
+ os.makedirs(os.path.join(out_dir, "figures"), exist_ok=True)
239
+ os.makedirs(os.path.join(out_dir, "tables"), exist_ok=True)
240
+ pdf = pdfplumber.open(pdf_path)
241
+ # 쪽을 돌기 전에 멈춘다. ingest가 이 꼴을 stderr에서 읽어 사람이 읽을 오류로 바꾼다
242
+ if len(pdf.pages) > MAX_PAGES:
243
+ sys.stderr.write("too-many-pages %d %d\n" % (len(pdf.pages), MAX_PAGES))
244
+ sys.exit(3)
245
+ pages, units, tables, figures = [], [], [], []
246
+ body_sizes = []
247
+ page_lines = []
248
+ for page in pdf.pages:
249
+ words = page.extract_words(x_tolerance=1.5, extra_attrs=["size", "fontname"])
250
+ lines = cluster_lines(words)
251
+ page_lines.append(lines)
252
+ body_sizes += [round(l["size"], 1) for l in lines]
253
+ body = statistics.mode(body_sizes) if body_sizes else 10.0
254
+
255
+ pending = None # 단이나 쪽을 넘어 이어지는 문단
256
+ for pi, page in enumerate(pdf.pages):
257
+ pno = pi + 1
258
+ W, H = page.width, page.height
259
+ mid = W / 2
260
+ lines = [l for l in page_lines[pi] if not (l["top"] > H - 70 and re.fullmatch(r"\d{1,4}", l["text"].strip()))]
261
+ captions = [l for l in lines if CAPTION.match(l["text"])]
262
+ # 캡션은 이어지는 줄까지 붙인다
263
+ cap_blocks = []
264
+ for c in captions:
265
+ block = [c]
266
+ for l in sorted(lines, key=lambda l: l["top"]):
267
+ if l is c or l["top"] <= block[-1]["top"]:
268
+ continue
269
+ if overlap_x(l["x0"], l["x1"], c["x0"], c["x1"]) > 0 and l["top"] - block[-1]["bottom"] < 4 and abs(l["size"] - c["size"]) < 0.3 and not CAPTION.match(l["text"]):
270
+ if abs(l["size"] - body) < 0.3:
271
+ break
272
+ block.append(l)
273
+ cap_blocks.append(block)
274
+ regions = []
275
+ # 캡션 단위는 본문 뒤에 붙인다. 앞 쪽에서 넘어온 문단이 먼저 이어져야 읽는 순서가 맞다
276
+ page_units = []
277
+ tcaps = [b for b in cap_blocks if b[0]["text"].lower().startswith("table")]
278
+ for cap, box, ys in table_regions(page, [{**b[0], "bottom": b[-1]["bottom"]} for b in tcaps]):
279
+ m = CAPTION.match(cap["text"])
280
+ block = next(b for b in tcaps if b[0]["top"] == cap["top"] and b[0]["x0"] == cap["x0"])
281
+ grid = build_table(page, box, ys)
282
+ if not grid:
283
+ continue
284
+ tid = f"table-{m.group(2)}"
285
+ if any(t["id"] == tid for t in tables):
286
+ continue
287
+ cells = [c for r in grid for c in r]
288
+ # 칸이 하나뿐이거나 빈칸이 많으면 칸 경계를 잘못 읽은 표다. 뷰어는 원문 크롭을 앞에 둔다
289
+ loose = len(grid[0]) < 2 or sum(1 for c in cells if not c) > len(cells) * 0.3
290
+ name = f"tables/{tid}.png"
291
+ page.crop(box).to_image(resolution=72 * CROP_SCALE).original.save(os.path.join(out_dir, name))
292
+ tables.append({"id": tid, "number": m.group(2), "caption": join_text(block), "page": pno, "bbox": [round(v, 1) for v in box], "rows": grid, "grid": "loose" if loose else "ok", "image": name})
293
+ page_units.append({"page": pno, "role": "caption", "ref": tid, "text": join_text(block), "rects": [{"page": pno, "bbox": [round(min(l["x0"] for l in block), 1), round(block[0]["top"], 1), round(max(l["x1"] for l in block), 1), round(block[-1]["bottom"], 1)]}, {"page": pno, "bbox": [round(v, 1) for v in box]}]})
294
+ regions.append(box)
295
+ regions.append((min(l["x0"] for l in block), block[0]["top"], max(l["x1"] for l in block), block[-1]["bottom"]))
296
+ for block in cap_blocks:
297
+ m = CAPTION.match(block[0]["text"])
298
+ if not m.group(1).lower().startswith("fig"):
299
+ continue
300
+ fid = f"figure-{m.group(2)}"
301
+ if any(f["id"] == fid for f in figures):
302
+ continue
303
+ cap = {**block[0], "x1": max(l["x1"] for l in block), "bottom": block[-1]["bottom"]}
304
+ box = figure_region(page, cap, [l for l in lines if l not in block])
305
+ capbox = (min(l["x0"] for l in block), block[0]["top"], max(l["x1"] for l in block), block[-1]["bottom"])
306
+ regions.append(capbox)
307
+ fig = {"id": fid, "number": m.group(2), "caption": join_text(block), "page": pno}
308
+ rects = [{"page": pno, "bbox": [round(v, 1) for v in capbox]}]
309
+ if box:
310
+ regions.append(box)
311
+ fig["bbox"] = [round(v, 1) for v in box]
312
+ rects.append({"page": pno, "bbox": fig["bbox"]})
313
+ img = page.crop(box).to_image(resolution=72 * CROP_SCALE).original
314
+ name = f"figures/{fid}.png"
315
+ img.save(os.path.join(out_dir, name))
316
+ fig["image"] = name
317
+ figures.append(fig)
318
+ page_units.append({"page": pno, "role": "caption", "ref": fid, "text": join_text(block), "rects": rects})
319
+ # 쪽 그림
320
+ pimg = page.to_image(resolution=72 * PAGE_SCALE).original.convert("RGB")
321
+ pname = f"pages/p{pno}.jpg"
322
+ pimg.save(os.path.join(out_dir, pname), quality=80)
323
+ pages.append({"n": pno, "width": round(W, 1), "height": round(H, 1), "image": pname})
324
+
325
+ body_lines = [l for l in lines if not any(inside(l, r) for r in regions)]
326
+ crossing = sum(1 for l in body_lines if l["x0"] < mid - 8 and l["x1"] > mid + 8)
327
+ two_col = crossing < len(body_lines) * 0.35
328
+ def col(l):
329
+ if not two_col:
330
+ return 0
331
+ if l["x1"] <= mid + 8:
332
+ return 1
333
+ if l["x0"] >= mid - 8:
334
+ return 2
335
+ return 0
336
+ first_col_top = min([l["top"] for l in body_lines if col(l) > 0] or [H])
337
+ head = sorted([l for l in body_lines if col(l) == 0 and l["top"] < first_col_top], key=lambda l: l["top"])
338
+ tail = sorted([l for l in body_lines if col(l) == 0 and l["top"] >= first_col_top], key=lambda l: l["top"])
339
+ streams = [head] + [sorted([l for l in body_lines if col(l) == c], key=lambda l: l["top"]) for c in (1, 2)] + [tail]
340
+ for stream in streams:
341
+ # 아래첨자나 위첨자 줄은 글자만 흩어 놓아서 문단을 끊지 않게 미리 버린다
342
+ stream = [l for l in stream if not (l["size"] < body - 1.2 and len(l["text"]) < 15)]
343
+ if not stream:
344
+ continue
345
+ gaps = [b["top"] - a["top"] for a, b in zip(stream, stream[1:]) if 8 < b["top"] - a["top"] < 30]
346
+ step = statistics.median(gaps) if gaps else 13.0
347
+ left = statistics.median(l["x0"] for l in stream)
348
+ cur = []
349
+ for l in stream:
350
+ if not cur:
351
+ # 앞 단이나 앞 쪽에서 끝나지 않은 문단을 잇는다
352
+ at = next((k for k in range(len(units) - 1, -1, -1) if units[k] is pending), None)
353
+ if at is not None and abs(l["x0"] - left) < 3 and abs(l["size"] - body) < 0.6:
354
+ cur = [l]
355
+ # 이어 붙이기는 문단을 닫을 때 한다. 사이에 캡션이 끼어 있어도 원래 자리에 붙인다
356
+ l["_cont"] = at
357
+ pending = None
358
+ continue
359
+ cur = [l]
360
+ continue
361
+ prev = cur[-1]
362
+ new_para = (
363
+ l["top"] - prev["top"] > step * 1.4
364
+ or (l["x0"] - left > 4 and ENDS.search(prev["text"].strip()) is not None)
365
+ or (abs(l["size"] - prev["size"]) > 0.6 and min(len(l["text"]), len(prev["text"])) >= 15)
366
+ or (prev["size"] > body + 0.5)
367
+ )
368
+ if new_para:
369
+ close(cur, pno, units, body)
370
+ cur = [l]
371
+ else:
372
+ cur.append(l)
373
+ if cur:
374
+ last = close(cur, pno, units, body)
375
+ pending = last if last["role"] == "para" and not ENDS.search(last["text"]) else None
376
+ units.extend(page_units)
377
+ # 수식 조각은 앞 문단에 붙인다. 출처 단위가 문단이라 따로 두면 번호만 늘어난다
378
+ merged = []
379
+ for u in units:
380
+ letters = sum(1 for ch in u["text"] if ch.isalpha())
381
+ frag = u["role"] == "para" and (len(u["text"]) < 30 or letters < len(u["text"]) * 0.55)
382
+ prev = merged[-1] if merged else None
383
+ if frag and prev is not None and prev["role"] == "para" and prev["page"] == u["page"]:
384
+ prev["text"] = prev["text"] + " " + u["text"]
385
+ prev["rects"] = prev["rects"] + u["rects"]
386
+ continue
387
+ merged.append(u)
388
+ units[:] = merged
389
+ for i, u in enumerate(units):
390
+ u["i"] = i
391
+ for t in tables:
392
+ t["unit"] = next(u["i"] for u in units if u.get("ref") == t["id"])
393
+ for f in figures:
394
+ f["unit"] = next(u["i"] for u in units if u.get("ref") == f["id"])
395
+ meta = pdf.metadata or {}
396
+ print(json.dumps({"title": meta.get("Title") or "", "pageCount": len(pdf.pages), "pages": pages, "units": units, "tables": tables, "figures": figures}))
397
+
398
+
399
+ def close(cur, pno, units, body):
400
+ at = cur[0].pop("_cont", None)
401
+ text = join_text(cur)
402
+ rect = {"page": pno, "bbox": [round(min(l["x0"] for l in cur), 1), round(cur[0]["top"], 1), round(max(l["x1"] for l in cur), 1), round(cur[-1]["bottom"], 1)]}
403
+ if at is not None:
404
+ cont = units[at]
405
+ units[at] = {**cont, "text": join_text([{"text": cont["text"]}, {"text": text}]), "rects": cont["rects"] + [rect]}
406
+ return units[at]
407
+ heading = len(cur) == 1 and (cur[0]["bold"] or cur[0]["size"] > body + 0.5) and len(text) < 90 and not ENDS.search(text)
408
+ units.append({"page": pno, "role": "heading" if heading else "para", "text": text, "rects": [rect]})
409
+ return units[-1]
410
+
411
+
412
+ main()
413
+ `;
414
+ //# sourceMappingURL=pdf-extract-py.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pdf-extract-py.js","sourceRoot":"","sources":["../../../src/lens/pdf-extract-py.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+YvC,CAAC"}
@@ -0,0 +1,36 @@
1
+ /** 피드에서 고른 에피소드 */
2
+ export interface FeedEpisode {
3
+ show: string;
4
+ title: string;
5
+ audioUrl: string;
6
+ pubDate?: string;
7
+ link?: string;
8
+ }
9
+ /**
10
+ * RSS 피드에서 n번째(0부터, 최신순) 에피소드의 오디오 주소를 꺼낸다. XML 파서를 들이지 않으려고
11
+ * item과 enclosure 태그만 정규식으로 읽는다. 주소는 http(s)만 받는다
12
+ */
13
+ export declare function pickEpisode(xml: string, index?: number): FeedEpisode;
14
+ /** 오디오 확장자. 주소에 없으면 mp3로 둔다 */
15
+ export declare function audioExt(url: string): string;
16
+ /** 받아쓰기 스크립트. 진행 로그가 섞일 수 있어 결과는 마지막 줄 한 줄로 낸다 */
17
+ export declare const PODCAST_WHISPER_PY = "\nimport json, sys\nfrom faster_whisper import WhisperModel\naudio, model_name, lang = sys.argv[1], sys.argv[2], sys.argv[3] or None\nmodel = WhisperModel(model_name, device=\"auto\", compute_type=\"auto\")\nsegments, info = model.transcribe(audio, language=lang, vad_filter=True)\nout = [{\"start\": s.start, \"end\": s.end, \"text\": s.text.strip()} for s in segments if s.text.strip()]\nsys.stdout.write(\"\\n\" + json.dumps({\"language\": info.language, \"duration\": info.duration, \"segments\": out}) + \"\\n\")\n";
18
+ /**
19
+ * 화자 구분 스크립트. sherpa-onnx의 오프라인 화자 구분을 쓴다. pyannote 구간 분할 모델을 ONNX로 옮긴 것과
20
+ * 화자 임베딩 모델을 쓰고 둘 다 Hugging Face 토큰 없이 받는다. 오디오는 faster-whisper의 디코더로 16kHz로 읽는다
21
+ */
22
+ export declare const DIARIZE_PY = "\nimport json, sys\nimport sherpa_onnx\nfrom faster_whisper import decode_audio\naudio, seg_model, emb_model, threshold = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4])\ncfg = sherpa_onnx.OfflineSpeakerDiarizationConfig(\n segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(\n pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(model=seg_model)),\n embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=emb_model),\n clustering=sherpa_onnx.FastClusteringConfig(num_clusters=-1, threshold=threshold),\n min_duration_on=0.3, min_duration_off=0.5)\nsd = sherpa_onnx.OfflineSpeakerDiarization(cfg)\nsamples = decode_audio(audio, sampling_rate=sd.sample_rate)\nturns = sd.process(samples).sort_by_start_time()\nsys.stdout.write(\"\\n\" + json.dumps([{\"start\": t.start, \"end\": t.end, \"speaker\": t.speaker} for t in turns]) + \"\\n\")\n";
23
+ export interface SpeakerTurn {
24
+ start: number;
25
+ end: number;
26
+ speaker: number;
27
+ }
28
+ /**
29
+ * 받아쓴 문장마다 가장 오래 겹치는 화자 구간의 화자를 단다. 이름은 처음 나온 순서대로 "화자 1", "화자 2"다.
30
+ * 사람 이름은 받아쓰기에서 짐작하지 않는다. 겹치는 구간이 없는 문장은 화자를 비운다
31
+ */
32
+ export declare function assignSpeakers(segments: Array<{
33
+ start: number;
34
+ end: number;
35
+ }>, turns: SpeakerTurn[]): Array<string | undefined>;
36
+ //# sourceMappingURL=podcast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"podcast.d.ts","sourceRoot":"","sources":["../../../src/lens/podcast.ts"],"names":[],"mappings":"AAGA,mBAAmB;AACnB,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAiBD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,SAAI,GAAG,WAAW,CAkB/D;AAED,+BAA+B;AAC/B,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAS5C;AAED,kDAAkD;AAClD,eAAO,MAAM,kBAAkB,4gBAQ9B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,UAAU,w4BAetB,CAAC;AAEF,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,EAC/C,KAAK,EAAE,WAAW,EAAE,GACnB,KAAK,CAAC,MAAM,GAAG,SAAS,CAAC,CAgB3B"}
@@ -0,0 +1,104 @@
1
+ import { extname } from 'node:path';
2
+ import { AUDIO_EXTS } from './kind.js';
3
+ const decode = (s) => s
4
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
5
+ .replace(/&amp;/g, '&')
6
+ .replace(/&lt;/g, '<')
7
+ .replace(/&gt;/g, '>')
8
+ .replace(/&quot;/g, '"')
9
+ .replace(/&#39;|&apos;/g, "'")
10
+ .trim();
11
+ const tag = (xml, name) => {
12
+ const m = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, 'i').exec(xml);
13
+ return m ? decode(m[1]) : undefined;
14
+ };
15
+ /**
16
+ * RSS 피드에서 n번째(0부터, 최신순) 에피소드의 오디오 주소를 꺼낸다. XML 파서를 들이지 않으려고
17
+ * item과 enclosure 태그만 정규식으로 읽는다. 주소는 http(s)만 받는다
18
+ */
19
+ export function pickEpisode(xml, index = 0) {
20
+ const channel = /<channel[^>]*>([\s\S]*?)<item[\s>]/i.exec(xml)?.[1] ?? '';
21
+ const items = [...xml.matchAll(/<item[\s>][\s\S]*?<\/item>/gi)].map((m) => m[0]);
22
+ const item = items[index];
23
+ if (!item)
24
+ throw new Error(`피드에 ${index + 1}번째 에피소드가 없어요(에피소드 ${items.length}개)`);
25
+ const enclosure = /<enclosure\b[^>]*\burl=["']([^"']+)["']/i.exec(item)?.[1];
26
+ if (!enclosure)
27
+ throw new Error('에피소드에 오디오 주소(enclosure)가 없어요');
28
+ const audioUrl = decode(enclosure);
29
+ if (!/^https?:\/\//i.test(audioUrl))
30
+ throw new Error(`오디오 주소가 http(s)가 아니에요: ${audioUrl}`);
31
+ return {
32
+ show: tag(channel, 'title') ?? '',
33
+ title: tag(item, 'title') ?? '',
34
+ audioUrl,
35
+ ...(tag(item, 'pubDate') ? { pubDate: tag(item, 'pubDate') } : {}),
36
+ ...(tag(item, 'link') ? { link: tag(item, 'link') } : {}),
37
+ };
38
+ }
39
+ /** 오디오 확장자. 주소에 없으면 mp3로 둔다 */
40
+ export function audioExt(url) {
41
+ let path = url;
42
+ try {
43
+ path = new URL(url).pathname;
44
+ }
45
+ catch {
46
+ // 로컬 경로다
47
+ }
48
+ const ext = extname(path).toLowerCase();
49
+ return AUDIO_EXTS.includes(ext) ? ext : '.mp3';
50
+ }
51
+ /** 받아쓰기 스크립트. 진행 로그가 섞일 수 있어 결과는 마지막 줄 한 줄로 낸다 */
52
+ export const PODCAST_WHISPER_PY = `
53
+ import json, sys
54
+ from faster_whisper import WhisperModel
55
+ audio, model_name, lang = sys.argv[1], sys.argv[2], sys.argv[3] or None
56
+ model = WhisperModel(model_name, device="auto", compute_type="auto")
57
+ segments, info = model.transcribe(audio, language=lang, vad_filter=True)
58
+ out = [{"start": s.start, "end": s.end, "text": s.text.strip()} for s in segments if s.text.strip()]
59
+ sys.stdout.write("\\n" + json.dumps({"language": info.language, "duration": info.duration, "segments": out}) + "\\n")
60
+ `;
61
+ /**
62
+ * 화자 구분 스크립트. sherpa-onnx의 오프라인 화자 구분을 쓴다. pyannote 구간 분할 모델을 ONNX로 옮긴 것과
63
+ * 화자 임베딩 모델을 쓰고 둘 다 Hugging Face 토큰 없이 받는다. 오디오는 faster-whisper의 디코더로 16kHz로 읽는다
64
+ */
65
+ export const DIARIZE_PY = `
66
+ import json, sys
67
+ import sherpa_onnx
68
+ from faster_whisper import decode_audio
69
+ audio, seg_model, emb_model, threshold = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4])
70
+ cfg = sherpa_onnx.OfflineSpeakerDiarizationConfig(
71
+ segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
72
+ pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(model=seg_model)),
73
+ embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=emb_model),
74
+ clustering=sherpa_onnx.FastClusteringConfig(num_clusters=-1, threshold=threshold),
75
+ min_duration_on=0.3, min_duration_off=0.5)
76
+ sd = sherpa_onnx.OfflineSpeakerDiarization(cfg)
77
+ samples = decode_audio(audio, sampling_rate=sd.sample_rate)
78
+ turns = sd.process(samples).sort_by_start_time()
79
+ sys.stdout.write("\\n" + json.dumps([{"start": t.start, "end": t.end, "speaker": t.speaker} for t in turns]) + "\\n")
80
+ `;
81
+ /**
82
+ * 받아쓴 문장마다 가장 오래 겹치는 화자 구간의 화자를 단다. 이름은 처음 나온 순서대로 "화자 1", "화자 2"다.
83
+ * 사람 이름은 받아쓰기에서 짐작하지 않는다. 겹치는 구간이 없는 문장은 화자를 비운다
84
+ */
85
+ export function assignSpeakers(segments, turns) {
86
+ const names = new Map();
87
+ return segments.map((seg) => {
88
+ let best = -1;
89
+ let bestOverlap = 0;
90
+ for (const t of turns) {
91
+ const overlap = Math.min(seg.end, t.end) - Math.max(seg.start, t.start);
92
+ if (overlap > bestOverlap) {
93
+ bestOverlap = overlap;
94
+ best = t.speaker;
95
+ }
96
+ }
97
+ if (best < 0)
98
+ return undefined;
99
+ if (!names.has(best))
100
+ names.set(best, `화자 ${names.size + 1}`);
101
+ return names.get(best);
102
+ });
103
+ }
104
+ //# sourceMappingURL=podcast.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"podcast.js","sourceRoot":"","sources":["../../../src/lens/podcast.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ /** 렌즈 전용 가상환경. 시스템 파이썬의 pip가 오래돼 새 휠을 못 받는 머신에서 여기에 설치한다 */
2
+ export declare const LENS_VENV: string;
3
+ export declare const LENS_PY_INSTALL: string;
4
+ /**
5
+ * 렌즈 추출에 쓸 파이썬. GESTALT_LENS_PYTHON이 있으면 그 값을, 없으면 렌즈 가상환경을, 그것도 없으면 python3를 쓴다.
6
+ * 환경 변수는 절대 경로만 받는다. 상대 경로면 남의 레포 안 같은 이름 파일이 대신 돌 수 있다
7
+ */
8
+ export declare function lensPython(env?: NodeJS.ProcessEnv): string;
9
+ /** 모듈이 없을 때 설치 안내. 어떤 모듈이 빠졌는지 stderr에서 읽는다 */
10
+ export declare function missingModuleHint(stderr: string): string | null;
11
+ /** 화자 구분 모델 자리. GESTALT_LENS_MODELS가 있으면 그 절대 경로를 쓴다 */
12
+ export declare function lensModelsDir(env?: NodeJS.ProcessEnv): string;
13
+ /**
14
+ * 화자 구분에 쓰는 두 모델. sherpa-onnx가 GitHub 릴리스로 내준 파일이라 토큰 없이 받는다.
15
+ * 구간 분할은 pyannote segmentation 3.0을 ONNX로 옮긴 것이고 화자 임베딩은 wespeaker ResNet34(VoxCeleb)다.
16
+ * 릴리스 태그가 버전으로 고정돼 있지 않아 받은 파일의 sha256을 여기 박아 둔 값과 맞춰 본다
17
+ */
18
+ export declare const DIARIZE_MODELS: {
19
+ readonly segmentation: {
20
+ readonly archive: "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2";
21
+ /** 압축 파일의 해시다. 풀린 model.onnx가 아니다 */
22
+ readonly sha256: "24615ee884c897d9d2ba09bb4d30da6bb1b15e685065962db5b02e76e4996488";
23
+ readonly bytes: 6958444;
24
+ readonly file: string;
25
+ };
26
+ readonly embedding: {
27
+ readonly url: "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker_en_voxceleb_resnet34.onnx";
28
+ readonly sha256: "5ef208a9da1453335308a6b6f4e6dfbd7e183a38b604de0a57664f45d257fe94";
29
+ readonly bytes: 26534365;
30
+ readonly file: "wespeaker_en_voxceleb_resnet34.onnx";
31
+ };
32
+ };
33
+ //# sourceMappingURL=python.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"python.d.ts","sourceRoot":"","sources":["../../../src/lens/python.ts"],"names":[],"mappings":"AAIA,4DAA4D;AAC5D,eAAO,MAAM,SAAS,QAA6D,CAAC;AAEpF,eAAO,MAAM,eAAe,QAE4E,CAAC;AAEzG;;;GAGG;AACH,wBAAgB,UAAU,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAUvE;AAED,+CAA+C;AAC/C,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI/D;AAED,wDAAwD;AACxD,wBAAgB,aAAa,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAS1E;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;QAIvB,qCAAqC;;;;;;;;;;;CAW/B,CAAC"}