Add audio and video playback
Browse files- app.py +4 -4
- static/app.js +4 -3
- static/reader-contract.js +2 -0
- static/reader.css +3 -0
- static/reader.html +1 -1
- static/reader.js +16 -2
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
|
| 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 |
}
|