vomebook commited on
Commit
dbfea4d
·
verified ·
1 Parent(s): 1a6d78c

Add audio and video playback

Browse files
app.py CHANGED
@@ -66,7 +66,7 @@ repo_sorted_by_name = {}
66
  repo_sorted_by_size = {}
67
  txt_record_indices = []
68
  repo_txt_record_indices = {}
69
- READER_EXTENSIONS = frozenset({"pdf", "epub", "txt", "md", "markdown", "jpg", "jpeg", "png", "gif", "bmp", "webp"})
70
  reader_record_indices = []
71
  repo_reader_record_indices = {}
72
  reader_record_key_indices = {}
@@ -118,7 +118,7 @@ def decode_reader_assets(raw: bytes) -> dict:
118
  if entry.get("s") == 2:
119
  path = entry.get("p")
120
  mode = entry.get("m")
121
- if mode not in ("p", "e", "d", "h") or not isinstance(path, str) or not re.fullmatch(r"objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx|document\.html)", path):
122
  continue
123
  clean[key] = {"s": 2, "m": mode, "p": path}
124
  else:
@@ -391,7 +391,7 @@ def validate_voiceofml_source_url(url: str) -> str:
391
 
392
 
393
  READER_ASSET_SOURCE_RE = re.compile(
394
- r"^/datasets/vomebook/Reader-Assets/resolve/main/objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx|document\.html)$"
395
  )
396
  VOICEOFML_READER_SOURCE_RE = re.compile(r"^/datasets/VoiceOfML/[A-Za-z0-9._-]+/(?:resolve|raw)/main/.+$")
397
 
@@ -1419,7 +1419,7 @@ async def api_random_reader(repo: Optional[str] = Query(default=None)):
1419
  asset = converted.get(index)
1420
  if asset:
1421
  rec["ReaderLink"] = f"https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/{asset['p']}"
1422
- rec["ReaderExtension"] = {"p": "pdf", "e": "epub", "d": "docx", "h": "html"}[asset["m"]]
1423
  rec["DownloadLink"] = rec["Link"]
1424
  return JSONResponse(rec)
1425
 
 
66
  repo_sorted_by_size = {}
67
  txt_record_indices = []
68
  repo_txt_record_indices = {}
69
+ READER_EXTENSIONS = frozenset({"pdf", "epub", "txt", "md", "markdown", "jpg", "jpeg", "png", "gif", "bmp", "webp", "mp3", "mp4", "wav", "m4a", "flac", "mov", "mpga"})
70
  reader_record_indices = []
71
  repo_reader_record_indices = {}
72
  reader_record_key_indices = {}
 
118
  if entry.get("s") == 2:
119
  path = entry.get("p")
120
  mode = entry.get("m")
121
+ if mode not in ("p", "e", "d", "h", "a", "v") or not isinstance(path, str) or not re.fullmatch(r"objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx|document\.html|audio\.mp3|video\.mp4)", path):
122
  continue
123
  clean[key] = {"s": 2, "m": mode, "p": path}
124
  else:
 
391
 
392
 
393
  READER_ASSET_SOURCE_RE = re.compile(
394
+ r"^/datasets/vomebook/Reader-Assets/resolve/main/objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx|document\.html|audio\.mp3|video\.mp4)$"
395
  )
396
  VOICEOFML_READER_SOURCE_RE = re.compile(r"^/datasets/VoiceOfML/[A-Za-z0-9._-]+/(?:resolve|raw)/main/.+$")
397
 
 
1419
  asset = converted.get(index)
1420
  if asset:
1421
  rec["ReaderLink"] = f"https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/{asset['p']}"
1422
+ rec["ReaderExtension"] = {"p": "pdf", "e": "epub", "d": "docx", "h": "html", "a": "audio", "v": "video"}[asset["m"]]
1423
  rec["DownloadLink"] = rec["Link"]
1424
  return JSONResponse(rec)
1425
 
static/app.js CHANGED
@@ -711,10 +711,11 @@ function loadReaderAssets() {
711
 
712
  function applyReaderAsset(record, repo, relativePath, originalLink) {
713
  const asset = readerAssets && readerAssets[`${repo}\0${relativePath}`];
714
- if (!asset || asset.s !== 2 || !["p", "e", "d", "h"].includes(asset.m) || !/^objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?(document\.pdf|book\.epub|document\.docx|document\.html)$/.test(asset.p || "")) return record;
 
715
  return Object.assign({}, record, {
716
  ReaderLink: `https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/${asset.p}`,
717
- ReaderExtension: asset.m === "p" ? "pdf" : asset.m === "e" ? "epub" : asset.m === "d" ? "docx" : "html",
718
  DownloadLink: originalLink,
719
  });
720
  }
@@ -1502,7 +1503,7 @@ function buildResultHTML(rec, idx) {
1502
  <button class="result-action-btn" data-action="copy" data-link="${escapeHTML(getCopyableLink(recordLink))}">复制链接</button>
1503
  <button class="result-action-btn primary" data-action="download" data-filename="${escapeHTML(rec.File + (rec.Extension ? '.' + rec.Extension : ''))}" data-link="${escapeHTML(recordLink)}">下载</button>
1504
  <a href="${escapeHTML(getPreviewLink(recordPath))}" class="result-action-btn" target="_blank" rel="noopener noreferrer">仓库查看</a>
1505
- ${isReadableRecord(readerRecord) ? `<button class="result-action-btn" data-action="read" data-reader-url="${escapeHTML(getReaderLink(readerRecord))}">在线阅读</button>` : ""}
1506
  </div>`;
1507
  }
1508
 
 
711
 
712
  function applyReaderAsset(record, repo, relativePath, originalLink) {
713
  const asset = readerAssets && readerAssets[`${repo}\0${relativePath}`];
714
+ if (!asset || asset.s !== 2 || !["p", "e", "d", "h", "a", "v"].includes(asset.m) || !/^objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?(document\.pdf|book\.epub|document\.docx|document\.html|audio\.mp3|video\.mp4)$/.test(asset.p || "")) return record;
715
+ const readerExtensions = { p: "pdf", e: "epub", d: "docx", h: "html", a: "audio", v: "video" };
716
  return Object.assign({}, record, {
717
  ReaderLink: `https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/${asset.p}`,
718
+ ReaderExtension: readerExtensions[asset.m],
719
  DownloadLink: originalLink,
720
  });
721
  }
 
1503
  <button class="result-action-btn" data-action="copy" data-link="${escapeHTML(getCopyableLink(recordLink))}">复制链接</button>
1504
  <button class="result-action-btn primary" data-action="download" data-filename="${escapeHTML(rec.File + (rec.Extension ? '.' + rec.Extension : ''))}" data-link="${escapeHTML(recordLink)}">下载</button>
1505
  <a href="${escapeHTML(getPreviewLink(recordPath))}" class="result-action-btn" target="_blank" rel="noopener noreferrer">仓库查看</a>
1506
+ ${isReadableRecord(readerRecord) ? `<button class="result-action-btn" data-action="read" data-reader-url="${escapeHTML(getReaderLink(readerRecord))}">${["audio", "video"].includes(VoiceOfMLReader.capability(readerRecord.ReaderExtension || readerRecord.Extension).mode) ? "在线播放" : "在线阅读"}</button>` : ""}
1507
  </div>`;
1508
  }
1509
 
static/reader-contract.js CHANGED
@@ -11,6 +11,8 @@
11
  const modes = Object.freeze({
12
  pdf: "pdf", epub: "epub", docx: "docx", html: "html", htm: "html", txt: "text", md: "markdown", markdown: "markdown",
13
  jpg: "image", jpeg: "image", png: "image", gif: "image", bmp: "image", webp: "image",
 
 
14
  });
15
  const articleExtensions = Object.freeze(Object.keys(modes));
16
 
 
11
  const modes = Object.freeze({
12
  pdf: "pdf", epub: "epub", docx: "docx", html: "html", htm: "html", txt: "text", md: "markdown", markdown: "markdown",
13
  jpg: "image", jpeg: "image", png: "image", gif: "image", bmp: "image", webp: "image",
14
+ mp3: "audio", wav: "audio", m4a: "audio", flac: "audio", mpga: "audio", audio: "audio",
15
+ mp4: "video", mov: "video", video: "video",
16
  });
17
  const articleExtensions = Object.freeze(Object.keys(modes));
18
 
static/reader.css CHANGED
@@ -35,6 +35,9 @@ body { margin: 0; height: 100vh; height: 100dvh; overflow: hidden; display: flex
35
  .reader-page canvas { position: relative; opacity: 0; transition: opacity 120ms ease; }
36
  .reader-page canvas.ready { opacity: 1; }
37
  .reader-image { display: block; width: calc(100% * var(--reader-zoom)); max-width: none; height: auto; margin: 0 auto; background: #fff; }
 
 
 
38
  .reader-text { margin: 0 auto; max-width: 76ch; white-space: pre-wrap; overflow-wrap: anywhere; font: calc(17px * var(--reader-zoom))/1.8 ui-monospace, monospace; color: #e7e9eb; }
39
  .reader-markdown { margin: 0 auto; max-width: 76ch; font-size: calc(17px * var(--reader-zoom)); line-height: 1.75; }
40
  .reader-markdown img { max-width: 100%; }
 
35
  .reader-page canvas { position: relative; opacity: 0; transition: opacity 120ms ease; }
36
  .reader-page canvas.ready { opacity: 1; }
37
  .reader-image { display: block; width: calc(100% * var(--reader-zoom)); max-width: none; height: auto; margin: 0 auto; background: #fff; }
38
+ .reader-content[data-mode="audio"], .reader-content[data-mode="video"] { width: 100%; height: 100%; padding: 0; display: grid; place-items: center; }
39
+ .reader-audio { display: block; width: min(720px, calc(100% - 32px)); }
40
+ .reader-video { display: block; width: 100%; height: 100%; max-height: calc(100dvh - 44px); object-fit: contain; background: #000; }
41
  .reader-text { margin: 0 auto; max-width: 76ch; white-space: pre-wrap; overflow-wrap: anywhere; font: calc(17px * var(--reader-zoom))/1.8 ui-monospace, monospace; color: #e7e9eb; }
42
  .reader-markdown { margin: 0 auto; max-width: 76ch; font-size: calc(17px * var(--reader-zoom)); line-height: 1.75; }
43
  .reader-markdown img { max-width: 100%; }
static/reader.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <meta name="theme-color" content="#181a1d">
7
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' blob:; img-src 'self' data: blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; connect-src 'self' blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; worker-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; base-uri 'self' blob:; form-action 'none'; object-src 'none'">
8
  <title>VoiceOfML Reader</title>
9
  <link rel="preconnect" href="https://huggingface.co" crossorigin>
10
  <link rel="stylesheet" href="/static/reader.css">
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <meta name="theme-color" content="#181a1d">
7
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' blob:; img-src 'self' data: blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; media-src 'self'; connect-src 'self' blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; worker-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; base-uri 'self' blob:; form-action 'none'; object-src 'none'">
8
  <title>VoiceOfML Reader</title>
9
  <link rel="preconnect" href="https://huggingface.co" crossorigin>
10
  <link rel="stylesheet" href="/static/reader.css">
static/reader.js CHANGED
@@ -76,13 +76,14 @@ const zoomInput = document.querySelector("#zoom");
76
  const pageInput = document.querySelector("#page-number");
77
  const readerPath = document.querySelector("#reader-path");
78
  const loadingObserver = new MutationObserver(() => {
79
- if (content.querySelector(".reader-page, .reader-image, .reader-text, .reader-markdown, .html-frame, .epub-frame, .docx-body")) {
80
  loadingIndicator.remove();
81
  loadingObserver.disconnect();
82
  }
83
  });
84
  loadingObserver.observe(content, { childList: true });
85
  document.querySelector(".page-controls").hidden = capability.mode !== "pdf";
 
86
 
87
  document.querySelector("#title").textContent = title;
88
  document.title = title + " - VoiceOfML Reader";
@@ -196,7 +197,7 @@ function validSource(raw) {
196
  try {
197
  const url = new URL(raw);
198
  if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname)) return false;
199
- const readerAsset = /^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?(document\.pdf|book\.epub|document\.docx|document\.html)$/.test(url.pathname);
200
  if (extension === "docx") return readerAsset;
201
  return /^\/datasets\/VoiceOfML\/[^/]+\/(resolve|raw)\//.test(url.pathname) || readerAsset;
202
  } catch (_) { return false; }
@@ -510,6 +511,17 @@ async function renderDocx(prepared) {
510
  }
511
  status.textContent = "DOCX";
512
  }
 
 
 
 
 
 
 
 
 
 
 
513
  async function start() {
514
  if (!validSource(sourceUrl) || capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED) return fail("此文件暂不支持在线阅读,请下载原文件。");
515
  document.querySelector("#download").href = `/api/download?file=${encodeURIComponent(title)}&link=${encodeURIComponent(downloadUrl)}`;
@@ -528,6 +540,7 @@ async function start() {
528
  else if (capability.mode === "html") await renderHtml(prepared);
529
  else if (capability.mode === "epub") await renderEpub(prepared);
530
  else if (capability.mode === "docx") await renderDocx(prepared);
 
531
  loadingIndicator.remove();
532
  loadingStatus.hidden = true;
533
  if (!pageCount && restoredEntry) viewport.scrollTop = restoredEntry.scrollTop || 0;
@@ -541,6 +554,7 @@ function prepareDocument() {
541
  if (capability.mode === "text") return fetch(contentUrl).then((response) => ({ response }));
542
  if (capability.mode === "epub") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(EPUB_URL))]);
543
  if (capability.mode === "docx") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(DOCX_PREVIEW_URL))]);
 
544
  if (capability.mode === "image") return new Promise((resolve, reject) => { const image = new Image(); image.className = "reader-image"; image.alt = title; image.decoding = "async"; let fallback = false; image.onload = () => resolve(image); image.onerror = () => { if (!fallback) { fallback = true; image.src = sourceUrl; } else reject(new Error("image load failed")); }; image.src = contentUrl; });
545
  return Promise.resolve(null);
546
  }
 
76
  const pageInput = document.querySelector("#page-number");
77
  const readerPath = document.querySelector("#reader-path");
78
  const loadingObserver = new MutationObserver(() => {
79
+ if (content.querySelector(".reader-page, .reader-image, .reader-audio, .reader-video, .reader-text, .reader-markdown, .html-frame, .epub-frame, .docx-body")) {
80
  loadingIndicator.remove();
81
  loadingObserver.disconnect();
82
  }
83
  });
84
  loadingObserver.observe(content, { childList: true });
85
  document.querySelector(".page-controls").hidden = capability.mode !== "pdf";
86
+ document.querySelector(".zoom-controls").hidden = ["audio", "video"].includes(capability.mode);
87
 
88
  document.querySelector("#title").textContent = title;
89
  document.title = title + " - VoiceOfML Reader";
 
197
  try {
198
  const url = new URL(raw);
199
  if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname)) return false;
200
+ const readerAsset = /^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?(document\.pdf|book\.epub|document\.docx|document\.html|audio\.mp3|video\.mp4)$/.test(url.pathname);
201
  if (extension === "docx") return readerAsset;
202
  return /^\/datasets\/VoiceOfML\/[^/]+\/(resolve|raw)\//.test(url.pathname) || readerAsset;
203
  } catch (_) { return false; }
 
511
  }
512
  status.textContent = "DOCX";
513
  }
514
+ function renderMedia(mode) {
515
+ const media = document.createElement(mode);
516
+ media.className = mode === "audio" ? "reader-audio" : "reader-video";
517
+ media.controls = true;
518
+ media.preload = "metadata";
519
+ if (mode === "video") media.playsInline = true;
520
+ media.addEventListener("error", () => fail("媒体加载失败,请检查网络后重试,或下载原文件。"), { once: true });
521
+ media.src = contentUrl;
522
+ content.appendChild(media);
523
+ status.textContent = mode === "audio" ? "音频" : "视频";
524
+ }
525
  async function start() {
526
  if (!validSource(sourceUrl) || capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED) return fail("此文件暂不支持在线阅读,请下载原文件。");
527
  document.querySelector("#download").href = `/api/download?file=${encodeURIComponent(title)}&link=${encodeURIComponent(downloadUrl)}`;
 
540
  else if (capability.mode === "html") await renderHtml(prepared);
541
  else if (capability.mode === "epub") await renderEpub(prepared);
542
  else if (capability.mode === "docx") await renderDocx(prepared);
543
+ else if (capability.mode === "audio" || capability.mode === "video") renderMedia(capability.mode);
544
  loadingIndicator.remove();
545
  loadingStatus.hidden = true;
546
  if (!pageCount && restoredEntry) viewport.scrollTop = restoredEntry.scrollTop || 0;
 
554
  if (capability.mode === "text") return fetch(contentUrl).then((response) => ({ response }));
555
  if (capability.mode === "epub") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(EPUB_URL))]);
556
  if (capability.mode === "docx") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(DOCX_PREVIEW_URL))]);
557
+ if (capability.mode === "audio" || capability.mode === "video") return Promise.resolve(null);
558
  if (capability.mode === "image") return new Promise((resolve, reject) => { const image = new Image(); image.className = "reader-image"; image.alt = title; image.decoding = "async"; let fallback = false; image.onload = () => resolve(image); image.onerror = () => { if (!fallback) { fallback = true; image.src = sourceUrl; } else reject(new Error("image load failed")); }; image.src = contentUrl; });
559
  return Promise.resolve(null);
560
  }