From d5da215a0f4ef6097076005a64342dc962b5017a Mon Sep 17 00:00:00 2001 From: Happyfunnysad <123390106+Happyfunnysad@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:02:21 +0300 Subject: [PATCH] feat: add SponsorBlock trimming and robust offscreen relay --- README.en.md | 2 + README.md | 2 + extension/background.js | 138 ++++++++++++++++++- extension/content_hook.js | 87 ++++++------ extension/content_ui.css | 2 + extension/content_ui.js | 169 ++++++++++++++---------- extension/manifest.json | 6 +- extension/offscreen.js | 270 ++++++++++++++++++++++++++------------ 8 files changed, 478 insertions(+), 198 deletions(-) diff --git a/README.en.md b/README.en.md index 310e54c..14fd3b5 100644 --- a/README.en.md +++ b/README.en.md @@ -25,6 +25,8 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your compatibility with older players, slow). - **Auto‑disables Autoplay** — the extension turns off YouTube's "Autoplay next" so the next video won't start on its own. +- **SponsorBlock** — optionally removes sponsor segments, self-promotion, and interaction + reminders; lookups use k-anonymity and never send the complete video ID. ## ⚠️ Requirements diff --git a/README.md b/README.md index 77561f1..3f62724 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ (перекодирование для совместимости со старыми плеерами, медленно). - **Авто-отключение автовоспроизведения** — расширение само выключает «Автовоспроизведение» YouTube, чтобы следующий ролик не запускался сам. +- **SponsorBlock** — по желанию вырезает спонсорские вставки, саморекламу и призывы + подписаться; поиск выполняется с k-анонимностью, без передачи идентификатора ролика целиком. ## ⚠️ Требования diff --git a/extension/background.js b/extension/background.js index c9ec559..a440804 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2,19 +2,110 @@ // performs the final chrome.downloads save. ffmpeg.wasm cannot run here (a // service worker has no DOM/Worker/document that ffmpeg needs), so all muxing // happens in the offscreen document; the worker only orchestrates. +// Also queries the SponsorBlock API (the content script cannot: the page's CORS +// applies there, while the extension's host_permissions apply here). -let creating = null; // de-dupe concurrent createDocument calls +let creating = null; // de-dupe concurrent createDocument calls +let progressTab = null; // tab that started the current job, for progress relay + +// Is the offscreen document actually responding? hasDocument() keeps returning +// true for a crashed (e.g. out-of-memory) document, so we ping it and recreate +// on silence — otherwise every later chunk message goes nowhere and the transfer +// dies with "передача данных прервалась". +function pingOffscreen() { + return Promise.race([ + chrome.runtime.sendMessage({ t: 'ytdl-ping' }) + .then((r) => !!(r && r.pong)).catch(() => false), + new Promise((res) => setTimeout(() => res(false), 2000)), + ]); +} + +// createDocument() resolves as soon as the document exists — NOT when offscreen.js +// has run. offscreen.html first loads the (large) vendor ffmpeg.js, so for a while +// there is a live document with no onMessage listener: every message sent in that +// window resolves to undefined and the transfer dies with "передача данных +// прервалась". So we always wait for a real pong before reporting readiness. +async function waitForOffscreen(attempts) { + for (let i = 0; i < attempts; i++) { + if (await pingOffscreen()) return true; + await new Promise((r) => setTimeout(r, 300)); + } + return false; +} async function ensureOffscreen() { - const has = await chrome.offscreen.hasDocument(); - if (has) return; - if (creating) { await creating; return; } + if (creating) { await creating; } + + if (await chrome.offscreen.hasDocument()) { + if (await waitForOffscreen(10)) return; // alive (or still booting) → done + try { await chrome.offscreen.closeDocument(); } catch (e) {} // crashed → recreate + } + creating = chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['WORKERS', 'BLOBS'], justification: 'Run ffmpeg.wasm to mux captured video and audio tracks into an MP4.', }); - try { await creating; } finally { creating = null; } + try { await creating; } catch (e) { /* may already exist — the ping below decides */ } + finally { creating = null; } + + if (!(await waitForOffscreen(40))) { + // Silence almost always means offscreen.js never ran, and the usual reason is + // a missing vendor build: offscreen.html loads vendor/ffmpeg/ffmpeg.js first, + // and if that 404s then FFmpegWASM is undefined, offscreen.js throws on its + // very first line and no message listener is ever registered. + const missing = await missingFiles(); + throw new Error(missing.length + ? 'в папке расширения нет файлов: ' + missing.join(', ') + + ' — скопируйте их из репозитория и перезагрузите расширение' + : 'offscreen-документ не отвечает'); + } +} + +async function missingFiles() { + const need = ['offscreen.html', 'offscreen.js', + 'vendor/ffmpeg/ffmpeg.js', 'vendor/ffmpeg/ffmpeg-core.js', 'vendor/ffmpeg/ffmpeg-core.wasm']; + const missing = []; + for (const f of need) { + try { + const r = await fetch(chrome.runtime.getURL(f), { method: 'GET' }); + if (!r.ok) missing.push(f); + } catch (e) { missing.push(f); } + } + return missing; +} + +// ---- SponsorBlock --------------------------------------------------------- +// Which segment categories get cut out of the download: +// sponsor — paid sponsor ads +// selfpromo — self-promotion / merch +// interaction — "like & subscribe" reminders +const SB_API = 'https://sponsor.ajay.app'; +const SB_CATEGORIES = ['sponsor', 'selfpromo', 'interaction']; + +async function sha256Hex(s) { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)); + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +// Privacy-preserving lookup (k-anonymity): we send only the first 4 hex chars of +// sha256(videoID); the server returns all matching videos and we pick ours locally, +// so the API never learns which exact video was requested. +async function sbFetchSegments(videoId) { + if (!videoId) return []; + const prefix = (await sha256Hex(videoId)).slice(0, 4); + const url = SB_API + '/api/skipSegments/' + prefix + + '?categories=' + encodeURIComponent(JSON.stringify(SB_CATEGORIES)); + const resp = await fetch(url); + if (resp.status === 404) return []; // no segments for this prefix + if (!resp.ok) throw new Error('SponsorBlock HTTP ' + resp.status); + const list = await resp.json(); + const entry = Array.isArray(list) ? list.find((v) => v.videoID === videoId) : null; + if (!entry || !Array.isArray(entry.segments)) return []; + return entry.segments + .filter((s) => (s.actionType ? s.actionType === 'skip' : true)) + .filter((s) => Array.isArray(s.segment) && s.segment[1] > s.segment[0]) + .map((s) => ({ start: s.segment[0], end: s.segment[1], category: s.category })); } chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { @@ -28,6 +119,43 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return true; // async } + // Proxy for everything the offscreen document owns (begin / chunk / finalize). + // The content script used to message the offscreen document directly, but a + // content script and an offscreen document are not guaranteed to share a + // messaging channel — the send resolved to undefined and the transfer died. + // The service worker CAN talk to the offscreen document, so it relays. + // ffmpeg progress travels offscreen → worker → tab: runtime.sendMessage from an + // extension page does not reach content scripts, only tabs.sendMessage does. + if (msg.t === 'ytdl-progress') { + if (progressTab != null) { + chrome.tabs.sendMessage(progressTab, msg).catch(() => {}); + } + return; + } + + if (msg.t === 'ytdl-proxy') { + (async () => { + const inner = msg.m || {}; + if (inner.t === 'ytdl-begin') { + progressTab = (sender && sender.tab && sender.tab.id) != null ? sender.tab.id : null; + await ensureOffscreen(); + } + const r = await chrome.runtime.sendMessage(inner); + if (r === undefined) return { ok: false, error: 'offscreen не ответил на ' + inner.t }; + return r; + })() + .then(sendResponse) + .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) })); + return true; // async + } + + if (msg.t === 'ytdl-sb-get') { + sbFetchSegments(String(msg.videoId || '')) + .then((segments) => sendResponse({ ok: true, segments })) + .catch((e) => sendResponse({ ok: false, error: String(e) })); + return true; // async + } + if (msg.t === 'ytdl-save') { // Offscreen finished muxing and handed us a blob URL to save. chrome.downloads.download({ url: msg.url, filename: msg.filename, saveAs: false }) diff --git a/extension/content_hook.js b/extension/content_hook.js index f42f98c..0ecdd60 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -182,7 +182,6 @@ const dur = v.duration; if (!isFinite(dur) || dur <= 0) throw new Error('duration unknown'); const capEnd = Math.min(opts.end && opts.end > 0 ? opts.end : dur, dur); - const capStart = Math.max(0, Math.min(opts.start || 0, Math.max(0, capEnd - 1))); const capId = vidId(); const prev = { paused: v.paused, rate: v.playbackRate, time: v.currentTime, muted: v.muted }; @@ -190,22 +189,26 @@ try { v.muted = true; } catch (e) {} try { v.pause(); } catch (e) {} + // Every seek we make goes through goTo so we remember where we asked the + // playhead to be. Anything further ahead than that was moved by someone else + // (see the external-skip rescue in the capture loop below). + let lastReq = v.currentTime; + const goTo = (t) => { lastReq = t; seekVia(t); }; + // Order matters: - // 1) switch to a low quality and seek to a position clearly DIFFERENT from - // capStart, so that seeking to capStart afterwards is a real jump. That jump - // forces BOTH tracks to re-fetch — important because the audio itag is the + // 1) switch to a low quality and seek to a NON-ZERO position (so position 0 is + // left unbuffered). Seeking to 0 later is then a real jump that forces BOTH + // tracks to re-fetch from the start — important because the audio itag is the // same Opus at every quality, so a quality switch alone won't re-init audio. - // 2) start recording, switch to the target quality, then seek to capStart. - // Capture begins at the requested fragment — not at the start of the video. - const preSeek = capStart > 10 ? 0 : Math.min(35, Math.max(1, dur - 5)); + // 2) start recording, switch to the target quality, then seek to 0. setQualityRaw(preQ); await sleep(500); - seekVia(preSeek); + goTo(Math.min(35, dur * 0.4)); await sleep(700); resetTracks(); store.capturing = true; setQualityRaw(targetQ); - seekVia(capStart); + goTo(0); await sleep(500); // wait until the tracks we need have their init before entering the capture loop @@ -224,44 +227,57 @@ } return t; }; - // Where the captured data actually begins: the player can only start at a segment - // boundary at or before capStart, so the file may lead in by a few seconds. The - // caller needs this to trim RELATIVE to the file (ffmpeg's -ss counts from the - // file's own start, not from the video's absolute timeline). - const bufferedStartAt = (t) => { - for (let i = 0; i < v.buffered.length; i++) { - if (v.buffered.start(i) <= t + 0.5 && v.buffered.end(i) >= t) return v.buffered.start(i); - } - return t; - }; - let capturedFrom = capStart; - let cursor = capStart, stall = 0; - const span = Math.max(0.1, capEnd - capStart); + let cursor = 0, stall = 0, rescues = 0; const started = Date.now(); try { try { v.pause(); } catch (e) {} - capturedFrom = Math.min(capStart, bufferedStartAt(capStart)); while (true) { await sleep(350); if (vidId() !== capId) throw new Error('видео переключилось во время захвата'); try { if (!v.paused) v.pause(); } catch (e) {} // keep it paused; buffering runs anyway + // --- external skip rescue ------------------------------------------ + // Another extension (typically the standalone SponsorBlock) jumps the + // playhead over sponsor segments. The skipped range is then never fetched, + // the contiguous buffer edge stops growing at the segment start, and the + // capture hangs there forever. The video is paused during capture, so ANY + // forward jump beyond what we asked for is external. Pull the playhead back: + // already-buffered ranges are not re-fetched (no duplicated bytes), and + // SponsorBlock does not re-skip a segment it has already skipped once. + if (v.currentTime > lastReq + 2) { + rescues++; + console.warn('[YTDL] внешняя перемотка ' + lastReq.toFixed(1) + ' → ' + + v.currentTime.toFixed(1) + ' — возвращаю плейхед (' + rescues + ')'); + if (rescues > 20) { + throw new Error('стороннее расширение перематывает видео (SponsorBlock?) — ' + + 'отключите его на youtube.com: встроенное вырезание уже есть здесь'); + } + goTo(Math.min(cursor, capEnd - 0.1)); + stall = 0; + continue; + } + const edge = bufferedEndAt(cursor); - onProgress(Math.min(0.99, Math.max(0, edge - capStart) / span)); - if (edge >= capEnd - 0.6) break; // range fully buffered → captured + onProgress(Math.min(0.99, edge / capEnd)); + if (edge >= capEnd - 0.6) break; // fully buffered → captured if (edge > cursor + 0.3) { // window extended → hop to the edge cursor = edge; - seekVia(Math.min(cursor, capEnd - 0.1)); + goTo(Math.min(cursor, capEnd - 0.1)); stall = 0; } else { // plateaued → nudge to re-trigger fetch stall++; - if (stall % 4 === 0) seekVia(Math.min(cursor + 0.1, capEnd - 0.1)); - if (stall >= 60) break; // ~21s with no progress → give up + if (stall % 4 === 0) goTo(Math.min(cursor + 0.1, capEnd - 0.1)); + if (stall >= 60) { // ~21s with no progress → give up + if (rescues > 0) { + throw new Error('захват встал на отрезке, который пропускает стороннее ' + + 'расширение (SponsorBlock?) — отключите его на youtube.com'); + } + break; + } } if (Date.now() - started > 20 * 60 * 1000) break; // hard cap } - capturedFrom = Math.min(capturedFrom, bufferedStartAt(capStart)); } finally { store.capturing = false; // restore player state @@ -272,7 +288,6 @@ if (!prev.paused) { try { v.play(); } catch (e) {} } } onProgress(1); - return { capturedFrom: Math.max(0, capturedFrom) }; } // ---- subtitles (read from the built-in transcript panel) ----------------- @@ -395,7 +410,7 @@ // ---- bridge to the isolated-world UI script ------------------------------ window.addEventListener('message', async (ev) => { if (ev.source !== window || !ev.data || ev.data.__ytdl_to_hook !== true) return; - const { cmd, reqId, height, format, start, end } = ev.data; + const { cmd, reqId, height, format, end } = ev.data; const reply = (payload, transfer) => window.postMessage( Object.assign({ __ytdl_from_hook: true, reqId }, payload), '*', transfer || []); try { @@ -413,17 +428,13 @@ // (360p) to save bandwidth while keeping video/audio as separate tracks. const targetQ = isMp3 ? 'medium' : (Q[height] || 'hd720'); const preQ = (targetQ === 'small' || targetQ === 'tiny' || targetQ === 'medium') ? 'tiny' : 'medium'; - const cap = await playthrough( - { targetQ, preQ, start, end, needVideo: !isMp3 }, + await playthrough( + { targetQ, preQ, end, needVideo: !isMp3 }, (pct) => reply({ progress: pct, phase: 'buffering' })); const aud = assemble('audio'); if (!aud) throw new Error('не удалось захватить аудио'); - const payload = { - ok: true, done: true, - capturedFrom: cap.capturedFrom, // where the captured file actually begins - audio: { mime: aud.mime, size: aud.bytes.byteLength }, - }; + const payload = { ok: true, done: true, audio: { mime: aud.mime, size: aud.bytes.byteLength } }; const transfers = [aud.bytes.buffer]; payload._a = aud.bytes.buffer; if (!isMp3) { diff --git a/extension/content_ui.css b/extension/content_ui.css index b134c30..3bca7d4 100644 --- a/extension/content_ui.css +++ b/extension/content_ui.css @@ -41,6 +41,8 @@ border-radius: 12px; padding: 6px; min-width: 240px; + max-height: 70vh; + overflow-y: auto; box-shadow: 0 8px 28px rgba(0,0,0,.55); font-family: "YouTube Sans", Roboto, Arial, sans-serif; } diff --git a/extension/content_ui.js b/extension/content_ui.js index 3602ab1..657c634 100644 --- a/extension/content_ui.js +++ b/extension/content_ui.js @@ -3,10 +3,6 @@ // then streams the captured tracks to the offscreen ffmpeg worker for muxing. (function () { const BTN_ID = 'ytdl-btn'; - // Clips up to this length get an exact (re-encoded) cut; longer ones are copied - // instantly and start at the keyframe before the requested point. Re-encoding costs - // roughly the clip's own length at 1080p, so ~1 minute is a comfortable ceiling. - const EXACT_CUT_MAX_SEC = 60; let reqSeq = 1; const pending = new Map(); @@ -101,6 +97,28 @@ function head(text) { const d = document.createElement('div'); d.className = 'ytdl-menu-head'; d.textContent = text; return d; } + // radio group builder (used for the format toggle and the SponsorBlock toggle) + function radioGroup(options, currentKey, storageKey) { + let current = currentKey; + const rows = []; + options.forEach((f) => { + const row = el('div', 'ytdl-menu-radio' + (current === f.key ? ' sel' : '')); + row.appendChild(el('span', 'ytdl-dot')); + const txt = el('span', 'ytdl-radio-txt'); + txt.appendChild(el('b', null, f.title)); + txt.appendChild(el('i', null, f.sub)); + row.appendChild(txt); + row.addEventListener('click', (ev) => { + ev.stopPropagation(); + current = f.key; + chrome.storage.local.set({ [storageKey]: f.key }); + rows.forEach((r, i) => r.classList.toggle('sel', options[i].key === current)); + }); + rows.push(row); + }); + return rows; + } + async function onClick(e) { e.stopPropagation(); if (menuEl) { closeMenu(); return; } @@ -110,7 +128,7 @@ if (!heights.includes(1080)) heights.unshift(1080); if (!heights.includes(720)) heights.push(720); const uniq = [...new Set(heights)].sort((a, b) => b - a); - const { transcode = false } = await chrome.storage.local.get('transcode'); + const { transcode = false, sbCut = true } = await chrome.storage.local.get(['transcode', 'sbCut']); menuEl = document.createElement('div'); menuEl.className = 'ytdl-menu'; @@ -169,30 +187,19 @@ subs.addEventListener('click', () => { closeMenu(); downloadSubtitles(info); }); menuEl.appendChild(subs); + // --- SponsorBlock toggle --- + menuEl.appendChild(head('SponsorBlock')); + radioGroup([ + { key: true, title: 'Вырезать рекламу', sub: 'спонсорские вставки, самореклама, «подпишись» — по данным sponsor.ajay.app' }, + { key: false, title: 'Не вырезать', sub: 'скачивать ролик как есть' }, + ], sbCut !== false, 'sbCut').forEach((r) => menuEl.appendChild(r)); + // --- video format toggle --- menuEl.appendChild(head('Формат видео')); - const formats = [ + radioGroup([ { key: false, title: 'Быстро', sub: 'VP9 в mp4, без перекодирования' }, { key: true, title: 'H.264 (совместимо)', sub: 'перекодирование, медленно' }, - ]; - let current = !!transcode; - const rows = []; - formats.forEach((f) => { - const row = el('div', 'ytdl-menu-radio' + (current === f.key ? ' sel' : '')); - row.appendChild(el('span', 'ytdl-dot')); - const txt = el('span', 'ytdl-radio-txt'); - txt.appendChild(el('b', null, f.title)); - txt.appendChild(el('i', null, f.sub)); - row.appendChild(txt); - row.addEventListener('click', (ev) => { - ev.stopPropagation(); - current = f.key; - chrome.storage.local.set({ transcode: f.key }); - rows.forEach((r, i) => r.classList.toggle('sel', formats[i].key === current)); - }); - rows.push(row); - menuEl.appendChild(row); - }); + ], !!transcode, 'transcode').forEach((r) => menuEl.appendChild(r)); document.body.appendChild(menuEl); const b = document.getElementById(BTN_ID).getBoundingClientRect(); @@ -249,6 +256,20 @@ } } + // Ask the background for SponsorBlock segments; returns only segments that + // overlap the selected fragment. Never throws — an unreachable API must not + // block the download itself. + async function fetchSponsorSegments(videoId, start, end) { + try { + const r = await chrome.runtime.sendMessage({ t: 'ytdl-sb-get', videoId }); + if (!r || !r.ok || !Array.isArray(r.segments)) return []; + return r.segments.filter((s) => s.end > start + 0.2 && s.start < end - 0.2); + } catch (e) { + console.warn('[Triangle] SponsorBlock недоступен:', e); + return []; + } + } + async function startDownload(opts, info) { const { format, height, start, end } = opts; const duration = Math.floor(info.duration || 0); @@ -257,63 +278,48 @@ const t = toast(); t.set('Готовлю ' + label + ' — загрузка сегментов…', 0.02); - const { transcode = false } = await chrome.storage.local.get('transcode'); + const { transcode = false, sbCut = true } = await chrome.storage.local.get(['transcode', 'sbCut']); + const doTranscode = isMp3 ? true : !!transcode; // mp3 always encodes + + let sbSegments = []; + if (sbCut !== false) { + t.set('SponsorBlock: проверяю сегменты…', 0.02); + sbSegments = await fetchSponsorSegments(info.videoId, start, end); + } + const sbNote = sbSegments.length + ? ' (вырезаю вставок: ' + sbSegments.length + ')' : ''; const onProg = (msg) => { if (msg && msg.t === 'ytdl-progress') { - t.set((isMp3 ? 'Кодирование MP3… ' : 'Точная обрезка (перекодирование)… ') + + t.set((isMp3 ? 'Кодирование MP3… ' : 'Перекодирование в H.264/AAC… ') + Math.round(msg.value * 100) + '%', 0.55 + msg.value * 0.45); } }; chrome.runtime.onMessage.addListener(onProg); try { - const result = await download({ height, format, start, end }, (d) => { + const result = await download({ height, format, end }, (d) => { t.set('Загрузка сегментов ' + label + '… ' + Math.round(d.progress * 100) + '%', d.progress * 0.5); }); + t.set((isMp3 ? 'Кодирование MP3…' + : (transcode ? 'Готовлю перекодирование (может занять дольше ролика)…' : 'Склейка дорожек…')) + sbNote, 0.55); const ext = isMp3 ? '.mp3' : '.mp4'; const filename = safeName(info.title) + (isMp3 ? '' : ' [' + height + 'p]') + fragSuffix(start, end, duration) + ext; - // Capture starts at a segment boundary at or before `start`, so trimming must be - // RELATIVE to the captured file — ffmpeg's -ss counts from the file's own start, - // not from the video's absolute timeline. - const capturedFrom = typeof result.capturedFrom === 'number' ? result.capturedFrom : start; - const trimStart = Math.max(0, start - capturedFrom); - const trimDuration = Math.max(0, end - start); - const isFragment = start > 0 || end < duration - 0.5; - - // A copied stream can only start on a keyframe, so an exact start needs - // re-encoding. That costs roughly the clip's own length, so we only do it - // automatically for short clips; longer ones stay instant and start at the - // keyframe just before the requested point. - const needsExactCut = isFragment && trimStart > 0.3; - const shortEnough = trimDuration > 0 && trimDuration <= EXACT_CUT_MAX_SEC; - const exactCut = !isMp3 && needsExactCut && shortEnough; - const doTranscode = isMp3 ? true : (!!transcode || exactCut); - const alignedStart = !isMp3 && needsExactCut && !doTranscode; - - t.set(isMp3 ? 'Кодирование MP3…' - : (exactCut ? 'Точная обрезка фрагмента (перекодирование)…' - : (transcode ? 'Перекодирование в H.264 (может занять дольше ролика)…' - : 'Склейка дорожек…')), 0.55); - const res = await muxViaOffscreen({ format, video: isMp3 ? null : result._v, audio: result._a, videoMime: result.video && result.video.mime, audioMime: result.audio && result.audio.mime, - filename, transcode: doTranscode, quickEncode: exactCut && !transcode, - trimStart, - // only limit duration when a real fragment was requested - trimDuration: isFragment ? trimDuration : 0, + filename, transcode: doTranscode, start, end, + sb: sbSegments, }); if (!res || !res.ok) throw new Error(res && res.error || 'mux failed'); - t.set('Готово: ' + (res.filename || filename) + - (alignedStart ? ' — начало выровнено по опорному кадру' : ''), 1); - t.hide(alignedStart ? 7000 : 4000); + t.set('Готово: ' + (res.filename || filename) + sbNote, 1); + t.hide(4000); } catch (err) { t.set('Ошибка: ' + (err.message || err), 1); t.hide(6000); @@ -333,27 +339,58 @@ return btoa(s); } - async function muxViaOffscreen(job) { + // Everything bound for the offscreen ffmpeg document goes through the service + // worker — a content script cannot reliably message an offscreen document. + function toOffscreen(m) { + return chrome.runtime.sendMessage({ t: 'ytdl-proxy', m }); + } + + async function muxAttempt(job) { const CHUNK = 4 * 1024 * 1024; - await chrome.runtime.sendMessage({ t: 'ytdl-ensure' }); - await chrome.runtime.sendMessage({ + const beg = await toOffscreen({ t: 'ytdl-begin', filename: job.filename, format: job.format, videoMime: job.videoMime, audioMime: job.audioMime, - transcode: !!job.transcode, quickEncode: !!job.quickEncode, - trimStart: job.trimStart || 0, trimDuration: job.trimDuration || 0, + transcode: !!job.transcode, start: job.start, end: job.end, + sb: job.sb || [], }); + if (!beg || !beg.ok) throw new Error('offscreen не принял задание: ' + ((beg && beg.error) || 'нет ответа')); const sendTrack = async (name, buf) => { if (!buf) return; const view = new Uint8Array(buf); for (let off = 0; off < view.length; off += CHUNK) { const slice = view.subarray(off, Math.min(off + CHUNK, view.length)); - const r = await chrome.runtime.sendMessage({ t: 'ytdl-chunk', track: name, b64: b64encode(slice) }); - if (!r || !r.ok) throw new Error('передача данных прервалась (' + name + ')'); + let r = null; + try { + r = await toOffscreen({ t: 'ytdl-chunk', track: name, b64: b64encode(slice) }); + } catch (e) { r = { ok: false, error: String(e) }; } + if (!r || !r.ok) { + throw new Error('передача данных прервалась (' + name + ', ' + + Math.round(off / 1048576) + ' МБ из ' + Math.round(view.length / 1048576) + + '): ' + ((r && r.error) || 'нет ответа')); + } } }; await sendTrack('video', job.video); await sendTrack('audio', job.audio); - return chrome.runtime.sendMessage({ t: 'ytdl-finalize' }); + return toOffscreen({ t: 'ytdl-finalize' }); + } + + // The offscreen ffmpeg document can die mid-transfer (usually OOM on long + // videos) — ytdl-ensure now health-checks and recreates it, and the captured + // buffers are still here in the page, so one clean retry is safe and cheap. + async function muxViaOffscreen(job) { + let lastErr = null; + for (let attempt = 0; attempt < 2; attempt++) { + try { + return await muxAttempt(job); + } catch (e) { + lastErr = e; + console.warn('[Triangle] передача в ffmpeg, попытка ' + (attempt + 1) + ':', e); + await new Promise((r) => setTimeout(r, 1000)); + } + } + throw new Error((lastErr && lastErr.message || lastErr) + + ' — похоже, ffmpeg упал (не хватило памяти?). Попробуйте меньший фрагмент или 720p.'); } const mo = new MutationObserver(() => ensureButton()); diff --git a/extension/manifest.json b/extension/manifest.json index 9bcc0af..01ba776 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,10 +1,10 @@ { "manifest_version": 3, "name": "Triangle Downloader", - "version": "1.4.0", - "description": "Скачивает открытое видео YouTube (720p/1080p mp4, mp3, выбор фрагмента), перехватывая поток самого плеера. Без yt-dlp и внешних сервисов.", + "version": "1.4.1", + "description": "Скачивает открытое видео YouTube (720p/1080p mp4, mp3, выбор фрагмента), перехватывая поток самого плеера. Вырезает рекламные вставки по данным SponsorBlock. Без yt-dlp и внешних сервисов.", "permissions": ["downloads", "offscreen", "storage"], - "host_permissions": ["*://www.youtube.com/*"], + "host_permissions": ["*://www.youtube.com/*", "https://sponsor.ajay.app/*"], "background": { "service_worker": "background.js" }, "content_scripts": [ { diff --git a/extension/offscreen.js b/extension/offscreen.js index 44317be..ea62447 100644 --- a/extension/offscreen.js +++ b/extension/offscreen.js @@ -3,12 +3,20 @@ // into a universally-playable H.264/AAC MP4, and hands the result to the background // for saving. The captured tracks are whatever the player streamed (typically AV1 // or VP9 video + Opus audio), so this re-encodes rather than remuxes. +// +// SponsorBlock: `acc.sb` holds [{start,end,category}] intervals (absolute video +// time) to remove from the output. +// * mp3 / H.264 modes already re-encode → we drop the intervals with +// aselect/select filters (frame-accurate). +// * fast copy mode can't filter → we stream-copy each kept span into its own +// part file and join them with the concat demuxer (cut points land on the +// nearest keyframe, so they can be off by a couple of seconds). const { FFmpeg } = FFmpegWASM; let ff = null; let ffLoading = null; -const acc = { video: [], audio: [], videoMime: '', audioMime: '', filename: 'video.mp4' }; +const acc = { video: [], audio: [], videoMime: '', audioMime: '', filename: 'video.mp4', sb: [] }; const ffLog = []; // ring buffer of recent ffmpeg log lines for error reporting async function getFF() { @@ -50,12 +58,106 @@ function extFor(mime) { return 'bin'; } +// ---- SponsorBlock interval math ------------------------------------------- +// Clip the raw segments to [start, end], drop tiny ones, sort, merge overlaps. +// Returns merged removal intervals in ABSOLUTE video time. +function mergeCuts(segs, start, end) { + const clipped = (segs || []) + .map((s) => [Math.max(start, Number(s.start) || 0), Math.min(end, Number(s.end) || 0)]) + .filter(([a, b]) => b - a > 0.2) + .sort((x, y) => x[0] - y[0]); + const merged = []; + for (const s of clipped) { + const last = merged[merged.length - 1]; + if (last && s[0] <= last[1] + 0.1) last[1] = Math.max(last[1], s[1]); + else merged.push(s); + } + return merged; +} + +// Invert removal intervals into the spans to KEEP within [start, end]. +// `end` may be Infinity (no explicit fragment end) → last span gets end=null. +function keepList(cuts, start, end) { + const keep = []; + let cur = start; + for (const [a, b] of cuts) { + if (a - cur > 0.5) keep.push([cur, a]); + cur = Math.max(cur, b); + } + if (!isFinite(end)) keep.push([cur, null]); + else if (end - cur > 0.5) keep.push([cur, end]); + return keep; +} + +// select/aselect expression that drops the removal intervals; times must already +// be RELATIVE to the input (-ss shifts timestamps to 0). +function dropExpr(relCuts) { + return relCuts.map(([a, b]) => 'between(t,' + a.toFixed(3) + ',' + b.toFixed(3) + ')').join('+'); +} + +// Fast mode with SponsorBlock: stream-copy each kept span, then concat. +// Tries mp4 first, falls back to webm (same as the normal fast path). +async function copyCutConcat(inst, vName, aName, keep) { + const variants = [ + { ext: 'mp4', type: 'video/mp4', extra: ['-strict', '-2'], concatExtra: ['-movflags', '+faststart'] }, + { ext: 'webm', type: 'video/webm', extra: [], concatExtra: [] }, + ]; + let lastErr = ''; + for (const v of variants) { + const made = []; + const clean = async () => { + for (const f of made) { try { await inst.deleteFile(f); } catch (e) {} } + try { await inst.deleteFile('list.txt'); } catch (e) {} + }; + let ok = true; + for (let i = 0; i < keep.length; i++) { + const [a, b] = keep[i]; + const part = 'part' + i + '.' + v.ext; + const t = b != null ? ['-t', String(Math.max(0.1, b - a))] : []; + ffLog.length = 0; + const ret = await inst.exec([ + '-ss', String(a), '-i', vName, '-ss', String(a), '-i', aName, + '-map', '0:v:0', '-map', '1:a:0', ...t, '-c', 'copy', ...v.extra, part, + ]); + if (ret !== 0) { + lastErr = 'ffmpeg код ' + ret + ' (нарезка ' + v.ext + '): ' + ffLog.slice(-6).join(' | '); + ok = false; break; + } + made.push(part); + } + if (ok) { + const list = made.map((p) => "file '" + p + "'").join('\n'); + await inst.writeFile('list.txt', new TextEncoder().encode(list)); + ffLog.length = 0; + const out = 'out.' + v.ext; + const ret = await inst.exec(['-f', 'concat', '-safe', '0', '-i', 'list.txt', '-c', 'copy', ...v.concatExtra, out]); + if (ret === 0) { + try { + const data = await inst.readFile(out); + if (data && data.length) { + await clean(); + try { await inst.deleteFile(out); } catch (e) {} + return { data, chosen: { out, type: v.type, ext: '.' + v.ext }, lastErr: '' }; + } + } catch (e) {} + } + lastErr = 'ffmpeg код ' + ret + ' (склейка ' + v.ext + '): ' + ffLog.slice(-6).join(' | '); + try { await inst.deleteFile(out); } catch (e) {} + } + await clean(); + } + return { data: null, chosen: null, lastErr }; +} + async function finalize() { const inst = await getFF(); const isMp3 = acc.format === 'mp3'; const aName = 'a.' + extFor(acc.audioMime); + // concat + release the chunk arrays immediately — on long videos the tracks are + // hundreds of MB, and holding chunks + concat + ffmpeg FS at once risks OOM const aBytes = concat(acc.audio); + acc.audio = []; if (!aBytes.length) throw new Error('пустые данные аудио'); await inst.writeFile(aName, aBytes); @@ -63,101 +165,93 @@ async function finalize() { if (!isMp3) { vName = 'v.' + extFor(acc.videoMime); const vBytes = concat(acc.video); + acc.video = []; if (!vBytes.length) throw new Error('пустые данные видео'); await inst.writeFile(vName, vBytes); } - // Fragment trim. IMPORTANT: -ss counts from the captured file's own beginning, and - // capture starts at a segment boundary at or before the requested start — so the - // offset here is RELATIVE (trimStart), never the absolute position in the video. - // Passing an absolute position produced an empty file (0 bytes of output). - const trimStart = Math.max(0, Number(acc.trimStart) || 0); - const trimDuration = Math.max(0, Number(acc.trimDuration) || 0); - // Re-encoding cuts frame-accurately, so it seeks to the exact requested point. - // A stream copy cannot: video can only start on a keyframe while audio would be cut - // precisely, which leaves the lead-in silent. So the copy path seeks nothing and just - // limits the length — both tracks start together at the keyframe before the request. - const exact = !!acc.transcode; - const seek = exact && trimStart > 0.05 ? ['-ss', trimStart.toFixed(3)] : []; - const limit = trimDuration > 0.05 - ? ['-t', (exact ? trimDuration : trimStart + trimDuration).toFixed(3)] - : []; - const inV = (s) => (vName ? [...s, '-i', vName] : []); - const inA = (s) => [...s, '-i', aName]; - // Stream copy can only cut on keyframes, so a trimmed copy starts at the keyframe - // BEFORE the requested point. MP4 can hide that lead-in with an edit list, but the - // skipped frames stay inside the file and players that take the duration from the - // media track then show a frozen tail at the end. So the copy path always normalizes - // timestamps (lead-in becomes ordinary content) and exact cuts are produced by - // re-encoding instead — see the "exact cut" decision in content_ui.js. - const ZERO = ['-avoid_negative_ts', 'make_zero']; - - const runs = []; - if (isMp3) { - runs.push({ - name: 'mp3', out: 'out.mp3', type: 'audio/mpeg', ext: '.mp3', - args: [...inA(seek), ...limit, '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', 'out.mp3'], - }); - } else if (acc.transcode) { - // Re-encode to H.264 + AAC. An automatic exact cut of a short clip favours speed - // (ultrafast is ~2× quicker at 1080p); the user-selected compatibility mode keeps - // the better-compressing preset. - const preset = acc.quickEncode ? 'ultrafast' : 'veryfast'; - runs.push({ - name: 'h264', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', - args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, - '-c:v', 'libx264', '-preset', preset, '-crf', '20', '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', 'out.mp4'], - }); - } else { - // Fast path: stream-copy the original tracks (VP9/Opus) into mp4 (seconds). - runs.push({ - name: 'mp4-copy', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', - args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, - '-c', 'copy', '-strict', '-2', ...ZERO, '-movflags', '+faststart', 'out.mp4'], - }); - if (seek.length || limit.length) { - // If trimming upsets the copy path, keep the whole captured range rather than fail - // (it covers the fragment, just aligned to segment boundaries). + // fragment trim: -ss before each input (keyframe seek), -t as output duration + const start = Math.max(0, Number(acc.start) || 0); + const end = Number(acc.end) || 0; + const effEnd = end > start ? end : Infinity; + const dur = isFinite(effEnd) ? effEnd - start : 0; + const seek = start > 0 ? ['-ss', String(start)] : []; + const limit = dur > 0 ? ['-t', String(dur)] : []; + const inV = vName ? [...seek, '-i', vName] : []; + const inA = [...seek, '-i', aName]; + + // SponsorBlock removal intervals (absolute), and relative to the -ss shift + let cuts = mergeCuts(acc.sb, start, effEnd); + let keep = keepList(cuts, start, effEnd); + if (!keep.length) { cuts = []; keep = keepList([], start, effEnd); } // everything flagged → keep as is + const relCuts = cuts.map(([a, b]) => [Math.max(0, a - start), b - start]); + const expr = relCuts.length ? dropExpr(relCuts) : ''; + + let data = null, chosen = null, lastErr = ''; + + // Fast copy mode with cuts: per-span copy + concat (no re-encode). + if (!isMp3 && !acc.transcode && cuts.length) { + ({ data, chosen, lastErr } = await copyCutConcat(inst, vName, aName, keep)); + if (!data) console.warn('[Triangle] вырезание в режиме copy не удалось, скачиваю без вырезания:', lastErr); + } + + if (!data) { + const runs = []; + if (isMp3) { + const af = expr + ? ['-af', "aselect='not(" + expr + ")',asetpts=N/SR/TB"] + : []; + runs.push({ + out: 'out.mp3', type: 'audio/mpeg', ext: '.mp3', + args: [...inA, ...limit, '-vn', ...af, '-c:a', 'libmp3lame', '-b:a', '192k', 'out.mp3'], + }); + } else if (acc.transcode) { + // Slow path: re-encode to H.264 + AAC so the file plays everywhere. + const maps = expr + ? ['-filter_complex', + "[0:v]select='not(" + expr + ")',setpts=N/FRAME_RATE/TB[v];" + + "[1:a]aselect='not(" + expr + ")',asetpts=N/SR/TB[a]", + '-map', '[v]', '-map', '[a]'] + : ['-map', '0:v:0', '-map', '1:a:0']; + runs.push({ + out: 'out.mp4', type: 'video/mp4', ext: '.mp4', + args: [...inV, ...inA, ...maps, ...limit, + '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', 'out.mp4'], + }); + } else { + // Fast path: stream-copy the original tracks (VP9/Opus) into mp4 (seconds). + // (Also the fallback when SponsorBlock cutting failed above.) + runs.push({ + out: 'out.mp4', type: 'video/mp4', ext: '.mp4', + args: [...inV, ...inA, '-map', '0:v:0', '-map', '1:a:0', ...limit, + '-c', 'copy', '-strict', '-2', '-movflags', '+faststart', 'out.mp4'], + }); + // If mp4 refuses these codecs, fall back to native WebM copy. runs.push({ - name: 'mp4-copy-untrimmed', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', - args: [...inV([]), ...inA([]), '-map', '0:v:0', '-map', '1:a:0', - '-c', 'copy', '-strict', '-2', '-avoid_negative_ts', 'make_zero', - '-movflags', '+faststart', 'out.mp4'], + out: 'out.webm', type: 'video/webm', ext: '.webm', + args: [...inV, ...inA, '-map', '0:v:0', '-map', '1:a:0', ...limit, '-c', 'copy', 'out.webm'], }); } - // Last resort if mp4 refuses these codecs. - runs.push({ - name: 'webm-copy', out: 'out.webm', type: 'video/webm', ext: '.webm', - args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, - '-c', 'copy', ...ZERO, 'out.webm'], - }); - } - let data = null, chosen = null; - const failures = []; - for (const run of runs) { - ffLog.length = 0; - let ret = -1; - try { ret = await inst.exec(run.args); } catch (e) { ret = -1; ffLog.push(String((e && e.message) || e)); } - if (ret === 0) { - try { - const out = await inst.readFile(run.out); - // a non-empty result only — a "successful" run can still yield an empty file - if (out && out.length > 1024) { data = out; chosen = run; break; } - failures.push(run.name + ': пустой результат'); - } catch (e) { failures.push(run.name + ': файл не создан'); } - } else { - failures.push(run.name + ' (код ' + ret + '): ' + ffLog.slice(-3).join(' | ')); + for (const run of runs) { + ffLog.length = 0; + const ret = await inst.exec(run.args); + if (ret === 0) { + try { + data = await inst.readFile(run.out); + if (data && data.length) { chosen = run; break; } + } catch (e) { /* try next */ } + } + lastErr = 'ffmpeg код ' + ret + ': ' + ffLog.slice(-6).join(' | '); + try { await inst.deleteFile(run.out); } catch (e) {} } - try { await inst.deleteFile(run.out); } catch (e) {} + if (chosen) { try { await inst.deleteFile(chosen.out); } catch (e) {} } } - const lastErr = failures.join(' || '); // free FS try { if (vName) await inst.deleteFile(vName); await inst.deleteFile(aName); } catch (e) {} - if (chosen) { try { await inst.deleteFile(chosen.out); } catch (e) {} } - acc.video = []; acc.audio = []; + acc.video = []; acc.audio = []; acc.sb = []; if (!chosen) throw new Error(lastErr || 'ffmpeg не собрал файл'); @@ -173,6 +267,10 @@ async function finalize() { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (!msg || typeof msg.t !== 'string') return; + if (msg.t === 'ytdl-ping') { + sendResponse({ pong: true }); + return; // sync + } if (msg.t === 'ytdl-begin') { acc.video = []; acc.audio = []; acc.videoMime = msg.videoMime || ''; @@ -180,9 +278,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { acc.filename = msg.filename || 'video.mp4'; acc.transcode = !!msg.transcode; acc.format = msg.format || 'mp4'; - acc.quickEncode = !!msg.quickEncode; - acc.trimStart = msg.trimStart || 0; - acc.trimDuration = msg.trimDuration || 0; + acc.start = msg.start || 0; + acc.end = msg.end || 0; + acc.sb = Array.isArray(msg.sb) ? msg.sb : []; // warm up ffmpeg while chunks stream in getFF().catch(() => {}); sendResponse({ ok: true });