Recently Written · git

subread-extension

Show your own .srt over a YouTube video, in time with it. Chrome and Firefox.

git clone https://github.com/equwal/subread-extension

Log | Files | Refs


commit 85ef71c5fe414ca0aefb1f237adb8b39c5b7c034
equwal <truex@equwal.com>
2026-09-24 11:12:39 -0700

Add store screenshots of two YouTube audiobooks and the marquee promo tile

The screenshots show Moby-Dick (LibriVox) and Kokoro with subtitles from the book text. tools/shots takes them again in Brave over CDP. The mock-page screenshot is removed.

 README.md                                       |   3 +-
 store/demo/kokoro-nishimura.srt                 |  87 ++++++++++++++++++++++++
 store/demo/moby-dick-librivox.srt               |  55 +++++++++++++++
 store/graphics/marquee-1400x560.png             | Bin 0 -> 410709 bytes
 store/graphics/screenshot-1-moby-dick.png       | Bin 0 -> 651428 bytes
 store/graphics/screenshot-1280x800.png          | Bin 121766 -> 0 bytes
 store/graphics/screenshot-2-moby-dick-panel.png | Bin 0 -> 666116 bytes
 store/graphics/screenshot-3-kokoro.png          | Bin 0 -> 517450 bytes
 store/graphics/screenshot-4-kokoro-panel.png    | Bin 0 -> 541591 bytes
 store/listing.md                                |  27 ++++++--
 tools/graphics.py                               |  37 ++++++----
 tools/shots/cdp.mjs                             |  21 ++++++
 tools/shots/shot.mjs                            |  36 ++++++++++
 13 files changed, 247 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 5c85131..39be336 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,8 @@ YouTube, and YouTube's terms do not allow it; this one only draws text.
 | `tests/srt.test.cjs` | `node --test tests/srt.test.cjs` |
 | `tests/mock/server.py` | A stand-in for a watch page, to try the content script by hand |
 | `tools/pack.py` | Makes `dist/subread-chrome.zip` and `dist/subread-firefox.zip` |
-| `tools/graphics.py` | Makes the Chrome screenshot and promo tile in `store/graphics/` |
+| `tools/graphics.py` | Makes the Chrome promo tiles in `store/graphics/` |
+| `tools/shots/` | Takes the store screenshots in Brave over CDP |
 | `store/listing.md` | The texts and answers for the two stores |
 
 ## Try it
diff --git a/store/demo/kokoro-nishimura.srt b/store/demo/kokoro-nishimura.srt
new file mode 100644
index 0000000..47ec39b
--- /dev/null
+++ b/store/demo/kokoro-nishimura.srt
@@ -0,0 +1,87 @@
+1
+00:00:02,000 --> 00:00:09,000
+こころ 夏目漱石
+
+2
+00:00:09,000 --> 00:00:16,000
+上 先生と私 一
+
+3
+00:00:16,000 --> 00:00:20,500
+私はその人を常に先生と呼んでいた。
+
+4
+00:00:20,500 --> 00:00:28,000
+だからここでもただ先生と書くだけで本名は打ち明けない。
+
+5
+00:00:28,000 --> 00:00:38,000
+これは世間を憚かる遠慮というよりも、その方が私にとって自然だからである。
+
+6
+00:00:38,000 --> 00:00:47,000
+私はその人の記憶を呼び起すごとに、すぐ「先生」といいたくなる。
+
+7
+00:00:47,000 --> 00:00:51,500
+筆を執っても心持は同じ事である。
+
+8
+00:00:51,500 --> 00:00:57,000
+よそよそしい頭文字などはとても使う気にならない。
+
+9
+00:00:57,000 --> 00:01:04,000
+私が先生と知り合いになったのは鎌倉である。
+
+10
+00:01:04,000 --> 00:01:09,000
+その時私はまだ若々しい書生であった。
+
+11
+00:01:09,000 --> 00:01:16,500
+暑中休暇を利用して海水浴に行った友達からぜひ来いという端書を受け取ったので、
+
+12
+00:01:16,500 --> 00:01:21,000
+私は多少の金を工面して、出掛ける事にした。
+
+13
+00:01:21,000 --> 00:01:27,000
+私は金の工面に二、三日を費やした。
+
+14
+00:01:27,000 --> 00:01:31,000
+ところが私が鎌倉に着いて三日と経たないうちに、
+
+15
+00:01:31,000 --> 00:01:38,000
+私を呼び寄せた友達は、急に国元から帰れという電報を受け取った。
+
+16
+00:01:38,000 --> 00:01:46,000
+電報には母が病気だからと断ってあったけれども友達はそれを信じなかった。
+
+17
+00:01:46,000 --> 00:01:56,000
+友達はかねてから国元にいる親たちに勧まない結婚を強いられていた。
+
+18
+00:01:56,000 --> 00:02:03,000
+彼は現代の習慣からいうと結婚するにはあまり年が若過ぎた。
+
+19
+00:02:03,000 --> 00:02:08,000
+それに肝心の当人が気に入らなかった。
+
+20
+00:02:08,000 --> 00:02:17,000
+それで夏休みに当然帰るべきところを、わざと避けて東京の近くで遊んでいたのである。
+
+21
+00:02:17,000 --> 00:02:23,000
+彼は電報を私に見せてどうしようと相談をした。
+
+22
+00:02:23,000 --> 00:02:28,000
+私にはどうしていいか分らなかった。
diff --git a/store/demo/moby-dick-librivox.srt b/store/demo/moby-dick-librivox.srt
new file mode 100644
index 0000000..7058cee
--- /dev/null
+++ b/store/demo/moby-dick-librivox.srt
@@ -0,0 +1,55 @@
+1
+00:00:14,000 --> 00:00:20,000
+MOBY-DICK; or, THE WHALE. By Herman Melville.
+
+2
+00:00:20,000 --> 00:00:22,000
+ETYMOLOGY.
+
+3
+00:00:22,000 --> 00:00:29,500
+(Supplied by a Late Consumptive Usher to a Grammar School.)
+
+4
+00:00:29,500 --> 00:00:37,000
+The pale Usher—threadbare in coat, heart, body, and brain; I see him now.
+
+5
+00:00:37,000 --> 00:00:42,000
+He was ever dusting his old lexicons and grammars, with a queer handkerchief,
+
+6
+00:00:42,000 --> 00:00:47,500
+mockingly embellished with all the gay flags of all the known nations of the world.
+
+7
+00:00:47,500 --> 00:00:55,000
+He loved to dust his old grammars; it somehow mildly reminded him of his mortality.
+
+8
+00:00:55,000 --> 00:01:03,000
+“While you take in hand to school others, and to teach them by what name a whale-fish is to be called in our tongue,
+
+9
+00:01:03,000 --> 00:01:10,000
+leaving out, through ignorance, the letter H, which almost alone maketh up the signification of the word,
+
+10
+00:01:10,000 --> 00:01:17,000
+you deliver that which is not true.” —Hackluyt.
+
+11
+00:01:17,000 --> 00:01:25,500
+“WHALE. * * * Sw. and Dan. hval. This animal is named from roundness or rolling;
+
+12
+00:01:25,500 --> 00:01:34,000
+for in Dan. hvalt is arched or vaulted.” —Webster’s Dictionary.
+
+13
+00:01:34,000 --> 00:01:42,000
+“WHALE. * * * It is more immediately from the Dut. and Ger. Wallen;
+
+14
+00:01:42,000 --> 00:01:51,000
+A.S. Walw-ian, to roll, to wallow.” —Richardson’s Dictionary.
diff --git a/store/graphics/marquee-1400x560.png b/store/graphics/marquee-1400x560.png
new file mode 100644
index 0000000..716b6a0
Binary files /dev/null and b/store/graphics/marquee-1400x560.png differ
diff --git a/store/graphics/screenshot-1-moby-dick.png b/store/graphics/screenshot-1-moby-dick.png
new file mode 100644
index 0000000..ed2cd87
Binary files /dev/null and b/store/graphics/screenshot-1-moby-dick.png differ
diff --git a/store/graphics/screenshot-1280x800.png b/store/graphics/screenshot-1280x800.png
deleted file mode 100644
index 11ff0f6..0000000
Binary files a/store/graphics/screenshot-1280x800.png and /dev/null differ
diff --git a/store/graphics/screenshot-2-moby-dick-panel.png b/store/graphics/screenshot-2-moby-dick-panel.png
new file mode 100644
index 0000000..17fed09
Binary files /dev/null and b/store/graphics/screenshot-2-moby-dick-panel.png differ
diff --git a/store/graphics/screenshot-3-kokoro.png b/store/graphics/screenshot-3-kokoro.png
new file mode 100644
index 0000000..390afe5
Binary files /dev/null and b/store/graphics/screenshot-3-kokoro.png differ
diff --git a/store/graphics/screenshot-4-kokoro-panel.png b/store/graphics/screenshot-4-kokoro-panel.png
new file mode 100644
index 0000000..b12cadf
Binary files /dev/null and b/store/graphics/screenshot-4-kokoro-panel.png differ
diff --git a/store/listing.md b/store/listing.md
index 7c4bbb4..1269f87 100644
--- a/store/listing.md
+++ b/store/listing.md
@@ -45,10 +45,25 @@ Private: the extension does not read, record or download the video or its sound,
 
 ## Graphics
 
-`python tools/graphics.py` makes `store/graphics/screenshot-1280x800.png` and
-`store/graphics/promo-440x280.png` for Chrome. AMO wants none but shows
-screenshots when there are some. The icon is `icons/128.png`.
+Upload the four `store/graphics/screenshot-*.png` (1280 x 800) to both stores.
+They show two YouTube audiobooks with the subtitles in `store/demo/`:
 
-The screenshot comes from the stand-in page, and its file button shows the
-language of the browser that took it. Replace it with a shot of a real YouTube
-audiobook, with the panel open and a line on screen.
+- Moby-Dick, LibriVox, read by Stewart Wills:
+  https://www.youtube.com/watch?v=PsNsjxIOcmw (text: Project Gutenberg #2701)
+- こころ 上, read by 西村俊彦:
+  https://www.youtube.com/watch?v=uPXpHYs7y3s (text: Aozora Bunko 773)
+
+The times of the lines come from the YouTube transcript. The words come from
+the book.
+
+To take them again, start Brave with the extension and a CDP port, then run
+`tools/shots/shot.mjs` for each shot:
+
+```
+brave --user-data-dir=%TEMP%\subread-shots --remote-debugging-port=9334 --load-extension=<this folder>
+node tools/shots/cdp.mjs tools/shots/shot.mjs <video url> <absolute .srt path> <seconds> <out.png> <panel 0|1>
+```
+
+`python tools/graphics.py` makes `promo-440x280.png` (small promo tile) and
+`marquee-1400x560.png` (marquee promo tile) for Chrome. The icon is
+`icons/128.png`.
diff --git a/tools/graphics.py b/tools/graphics.py
index 6f0e412..be83c4a 100644
--- a/tools/graphics.py
+++ b/tools/graphics.py
@@ -1,7 +1,8 @@
-"""Make the Chrome Web Store graphics in store/graphics/.
+"""Make the Chrome Web Store promo tiles in store/graphics/.
 
-screenshot-1280x800.png: docs/screenshots/player.png on a dark field.
 promo-440x280.png: the icon and the name.
+marquee-1400x560.png: the icon, the name and a line of text, next to a store screenshot.
+The screenshots come from tools/shots/ (see store/listing.md).
 """
 import pathlib
 from PIL import Image, ImageDraw, ImageFont
@@ -9,6 +10,8 @@ from PIL import Image, ImageDraw, ImageFont
 ROOT = pathlib.Path(__file__).resolve().parent.parent
 OUT = ROOT / "store" / "graphics"
 BG = (15, 15, 15)
+FG = (240, 240, 240)
+DIM = (170, 170, 170)
 
 def font(size: int) -> ImageFont.ImageFont:
     for name in ("segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf"):
@@ -18,26 +21,36 @@ def font(size: int) -> ImageFont.ImageFont:
             pass
     return ImageFont.load_default()
 
-def screenshot() -> None:
-    shot = Image.open(ROOT / "docs" / "screenshots" / "player.png").convert("RGB")
-    shot = shot.resize((1280, round(shot.height * 1280 / shot.width)), Image.LANCZOS)
-    canvas = Image.new("RGB", (1280, 800), BG)
-    canvas.paste(shot, (0, (800 - shot.height) // 2))
-    canvas.save(OUT / "screenshot-1280x800.png")
+def icon(size: int) -> Image.Image:
+    return Image.open(ROOT / "icons" / "128.png").convert("RGBA").resize((size, size), Image.LANCZOS)
 
 def promo() -> None:
     canvas = Image.new("RGB", (440, 280), BG)
-    icon = Image.open(ROOT / "icons" / "128.png").convert("RGBA")
-    canvas.paste(icon, ((440 - 128) // 2, 40), icon)
+    i = icon(128)
+    canvas.paste(i, ((440 - 128) // 2, 40), i)
     draw = ImageDraw.Draw(canvas)
     for text, size, y in (("SubRead", 36, 190), ("your subtitles on YouTube", 20, 236)):
         f = font(size)
         w = draw.textlength(text, font=f)
-        draw.text(((440 - w) / 2, y), text, font=f, fill=(240, 240, 240))
+        draw.text(((440 - w) / 2, y), text, font=f, fill=FG)
     canvas.save(OUT / "promo-440x280.png")
 
+def marquee() -> None:
+    canvas = Image.new("RGB", (1400, 560), BG)
+    # The right side: the video area of a screenshot, with the book line on it.
+    shot = Image.open(OUT / "screenshot-1-moby-dick.png").convert("RGB").crop((230, 56, 1040, 686))
+    shot = shot.resize((round(shot.width * 480 / shot.height), 480), Image.LANCZOS)
+    canvas.paste(shot, (1400 - shot.width - 40, 40))
+    draw = ImageDraw.Draw(canvas)
+    i = icon(112)
+    canvas.paste(i, (64, 96), i)
+    draw.text((64, 236), "SubRead", font=font(72), fill=FG)
+    for n, line in enumerate(("Read along with audiobooks on YouTube,", "with subtitles made from the book itself.")):
+        draw.text((64, 336 + n * 40), line, font=font(28), fill=DIM)
+    canvas.save(OUT / "marquee-1400x560.png")
+
 OUT.mkdir(parents=True, exist_ok=True)
-screenshot()
 promo()
+marquee()
 for p in sorted(OUT.iterdir()):
     print(p.relative_to(ROOT), Image.open(p).size)
diff --git a/tools/shots/cdp.mjs b/tools/shots/cdp.mjs
new file mode 100644
index 0000000..a1af29d
--- /dev/null
+++ b/tools/shots/cdp.mjs
@@ -0,0 +1,21 @@
+// Minimal CDP client for the Brave instance on port 9334.
+// Usage: node cdp.mjs <script.mjs>  — the script default-exports async (page) => result.
+const port = 9334;
+const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
+let target = targets.find(t => t.type === 'page');
+if (!target) target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json();
+const ws = new WebSocket(target.webSocketDebuggerUrl);
+await new Promise(r => ws.addEventListener('open', r, { once: true }));
+let id = 0; const pending = new Map(); const listeners = [];
+ws.addEventListener('message', e => {
+  const m = JSON.parse(e.data);
+  if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); }
+  else listeners.forEach(f => f(m));
+});
+const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const evaluate = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails)); return r.result.value; };
+const page = { send, sleep, evaluate, listeners };
+const mod = await import(new URL(process.argv[2], `file:///${process.cwd().replace(/\\/g, '/')}/`).href);
+try { const out = await mod.default(page, process.argv.slice(3)); if (out !== undefined) console.log(typeof out === 'string' ? out : JSON.stringify(out, null, 1)); }
+finally { ws.close(); }
diff --git a/tools/shots/shot.mjs b/tools/shots/shot.mjs
new file mode 100644
index 0000000..e6478f7
--- /dev/null
+++ b/tools/shots/shot.mjs
@@ -0,0 +1,36 @@
+// Load an .srt into SubRead on a video, seek, and save a 1280x800 screenshot.
+// args: url srtPath seconds outPng panel(0|1)
+import { writeFileSync } from 'node:fs';
+export default async (p, [url, srt, seconds, out, panel = '0']) => {
+  await p.send('Page.enable');
+  await p.send('DOM.enable');
+  await p.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
+  await p.send('Page.navigate', { url });
+  await p.sleep(3000);
+  await p.evaluate(`(async () => {
+    for (let i = 0; i < 40 && !(document.querySelector('video') && document.querySelector('.subread-button')); i++) await new Promise(r => setTimeout(r, 500));
+    const v = document.querySelector('video'); v.pause();
+    const cc = document.querySelector('.ytp-subtitles-button');
+    if (cc?.getAttribute('aria-pressed') === 'true') cc.click();
+    if (!document.querySelector('ytd-watch-flexy[theater]')) document.querySelector('.ytp-size-button')?.click();
+    await new Promise(r => setTimeout(r, 1500));
+    document.querySelector('.subread-button').click();
+  })()`);
+  const { result } = await p.send('Runtime.evaluate', { expression: `document.querySelector('.subread-file')` });
+  await p.send('DOM.setFileInputFiles', { files: [srt], objectId: result.objectId });
+  await p.sleep(1000);
+  const status = await p.evaluate(`(async () => {
+    const v = document.querySelector('video');
+    v.currentTime = ${Number(seconds)};
+    await new Promise(r => v.addEventListener('seeked', r, { once: true }));
+    v.pause();
+    ${panel === '1' ? '' : "document.querySelector('.subread-button').click();"}
+    window.scrollTo(0, 0);
+    document.querySelector('#movie_player')?.dispatchEvent(new MouseEvent('mouseleave'));
+    await new Promise(r => setTimeout(r, 2500));
+    return [document.querySelector('.subread-status')?.textContent, document.querySelector('.subread-line')?.textContent].join(' | ');
+  })()`);
+  const shot = await p.send('Page.captureScreenshot', { format: 'png' });
+  writeFileSync(out, Buffer.from(shot.data, 'base64'));
+  return status;
+};